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:
parent
eb5cebbcdf
commit
3dcbe3c631
25 changed files with 472 additions and 15 deletions
12
config.py
12
config.py
|
@ -20,7 +20,8 @@ CLIENT_WHITELIST = ['SERVER_HOSTNAME', 'PREFERRED_URL_SCHEME', 'MIXPANEL_KEY',
|
||||||
'AUTHENTICATION_TYPE', 'REGISTRY_TITLE', 'REGISTRY_TITLE_SHORT',
|
'AUTHENTICATION_TYPE', 'REGISTRY_TITLE', 'REGISTRY_TITLE_SHORT',
|
||||||
'CONTACT_INFO', 'AVATAR_KIND', 'LOCAL_OAUTH_HANDLER', 'DOCUMENTATION_LOCATION',
|
'CONTACT_INFO', 'AVATAR_KIND', 'LOCAL_OAUTH_HANDLER', 'DOCUMENTATION_LOCATION',
|
||||||
'DOCUMENTATION_METADATA', 'SETUP_COMPLETE', 'DEBUG', 'MARKETO_MUNCHKIN_ID',
|
'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):
|
def frontend_visible_config(config_dict):
|
||||||
|
@ -455,3 +456,12 @@ class DefaultConfig(ImmutableConfig):
|
||||||
TEAM_RESYNC_STALE_TIME = '30m'
|
TEAM_RESYNC_STALE_TIME = '30m'
|
||||||
TEAM_SYNC_WORKER_FREQUENCY = 60 # seconds
|
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
|
||||||
|
|
9
data/model/test/test_user.py
Normal file
9
data/model/test/test_user.py
Normal 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
|
|
@ -24,6 +24,7 @@ from util.names import format_robot_username, parse_robot_username
|
||||||
from util.validation import (validate_username, validate_email, validate_password,
|
from util.validation import (validate_username, validate_email, validate_password,
|
||||||
INVALID_PASSWORD_MESSAGE)
|
INVALID_PASSWORD_MESSAGE)
|
||||||
from util.backoff import exponential_backoff
|
from util.backoff import exponential_backoff
|
||||||
|
from util.timedeltastring import convert_to_timedelta
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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!')
|
logger.debug('Email and username are unique!')
|
||||||
|
|
||||||
try:
|
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:
|
for prompt in prompts:
|
||||||
create_user_prompt(new_user, prompt)
|
create_user_prompt(new_user, prompt)
|
||||||
|
|
||||||
|
@ -188,7 +190,20 @@ def change_send_invoice_email(user, invoice_email):
|
||||||
user.save()
|
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):
|
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.removed_tag_expiration_s = tag_expiration_s
|
||||||
user.save()
|
user.save()
|
||||||
|
|
||||||
|
|
|
@ -58,6 +58,7 @@ def org_view(o, teams):
|
||||||
if is_admin:
|
if is_admin:
|
||||||
view['invoice_email'] = o.invoice_email
|
view['invoice_email'] = o.invoice_email
|
||||||
view['invoice_email_address'] = o.invoice_email_address
|
view['invoice_email_address'] = o.invoice_email_address
|
||||||
|
view['tag_expiration_s'] = o.removed_tag_expiration_s
|
||||||
|
|
||||||
return view
|
return view
|
||||||
|
|
||||||
|
@ -139,10 +140,10 @@ class Organization(ApiResource):
|
||||||
'type': ['string', 'null'],
|
'type': ['string', 'null'],
|
||||||
'description': 'The email address at which to receive invoices',
|
'description': 'The email address at which to receive invoices',
|
||||||
},
|
},
|
||||||
'tag_expiration': {
|
'tag_expiration_s': {
|
||||||
'type': 'integer',
|
'type': 'integer',
|
||||||
'maximum': 2592000,
|
|
||||||
'minimum': 0,
|
'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)
|
logger.debug('Changing email address for organization: %s', org.username)
|
||||||
model.user.update_email(org, new_email)
|
model.user.update_email(org, new_email)
|
||||||
|
|
||||||
if 'tag_expiration' in org_data:
|
if features.CHANGE_TAG_EXPIRATION and 'tag_expiration_s' in org_data:
|
||||||
logger.debug('Changing organization tag expiration to: %ss', org_data['tag_expiration'])
|
logger.debug('Changing organization tag expiration to: %ss', org_data['tag_expiration_s'])
|
||||||
model.user.change_user_tag_expiration(org, org_data['tag_expiration'])
|
model.user.change_user_tag_expiration(org, org_data['tag_expiration_s'])
|
||||||
|
|
||||||
teams = model.team.get_teams_within_org(org)
|
teams = model.team.get_teams_within_org(org)
|
||||||
return org_view(org, teams)
|
return org_view(org, teams)
|
||||||
|
|
18
endpoints/api/test/test_organization.py
Normal file
18
endpoints/api/test/test_organization.py
Normal 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)
|
|
@ -122,7 +122,7 @@ def user_view(user, previous_username=None):
|
||||||
'invoice_email': user.invoice_email,
|
'invoice_email': user.invoice_email,
|
||||||
'invoice_email_address': user.invoice_email_address,
|
'invoice_email_address': user.invoice_email_address,
|
||||||
'preferred_namespace': not (user.stripe_id is None),
|
'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),
|
'prompts': model.user.get_user_prompts(user),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -210,10 +210,10 @@ class User(ApiResource):
|
||||||
'type': 'string',
|
'type': 'string',
|
||||||
'description': 'The user\'s email address',
|
'description': 'The user\'s email address',
|
||||||
},
|
},
|
||||||
'tag_expiration': {
|
'tag_expiration_s': {
|
||||||
'type': 'integer',
|
'type': 'integer',
|
||||||
'maximum': 2592000,
|
|
||||||
'minimum': 0,
|
'minimum': 0,
|
||||||
|
'description': 'The number of seconds for tag expiration',
|
||||||
},
|
},
|
||||||
'username': {
|
'username': {
|
||||||
'type': 'string',
|
'type': 'string',
|
||||||
|
@ -326,9 +326,9 @@ class User(ApiResource):
|
||||||
logger.debug('Changing invoice_email for user: %s', user.username)
|
logger.debug('Changing invoice_email for user: %s', user.username)
|
||||||
model.user.change_send_invoice_email(user, user_data['invoice_email'])
|
model.user.change_send_invoice_email(user, user_data['invoice_email'])
|
||||||
|
|
||||||
if 'tag_expiration' in user_data:
|
if features.CHANGE_TAG_EXPIRATION and 'tag_expiration_s' in user_data:
|
||||||
logger.debug('Changing user tag expiration to: %ss', user_data['tag_expiration'])
|
logger.debug('Changing user tag expiration to: %ss', user_data['tag_expiration_s'])
|
||||||
model.user.change_user_tag_expiration(user, user_data['tag_expiration'])
|
model.user.change_user_tag_expiration(user, user_data['tag_expiration_s'])
|
||||||
|
|
||||||
if ('invoice_email_address' in user_data and
|
if ('invoice_email_address' in user_data and
|
||||||
user_data['invoice_email_address'] != user.invoice_email_address):
|
user_data['invoice_email_address'] != user.invoice_email_address):
|
||||||
|
|
5
static/css/directives/ui/duration-input.css
Normal file
5
static/css/directives/ui/duration-input.css
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
.duration-input-element input {
|
||||||
|
display: inline-block;
|
||||||
|
width: 200px;
|
||||||
|
margin-right: 10px;
|
||||||
|
}
|
3
static/css/directives/ui/time-machine-settings.css
Normal file
3
static/css/directives/ui/time-machine-settings.css
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
.time-machine-settings-element .btn {
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
|
@ -10,6 +10,7 @@
|
||||||
<span class="empty" ng-if="!binding || binding.length == 0">No {{ itemTitle }}s defined</span>
|
<span class="empty" ng-if="!binding || binding.length == 0">No {{ itemTitle }}s defined</span>
|
||||||
<form class="form-control-container" ng-submit="addItem()">
|
<form class="form-control-container" ng-submit="addItem()">
|
||||||
<input type="text" class="form-control" placeholder="{{ placeholder }}"
|
<input type="text" class="form-control" placeholder="{{ placeholder }}"
|
||||||
|
ng-pattern="getRegexp(itemPattern)"
|
||||||
ng-model="newItemName" style="display: inline-block">
|
ng-model="newItemName" style="display: inline-block">
|
||||||
<button class="btn btn-default" style="display: inline-block">Add</button>
|
<button class="btn btn-default" style="display: inline-block">Add</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
|
@ -177,6 +177,53 @@
|
||||||
</div>
|
</div>
|
||||||
</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 -->
|
<!-- Redis -->
|
||||||
<div class="co-panel">
|
<div class="co-panel">
|
||||||
<div class="co-panel-heading">
|
<div class="co-panel-heading">
|
||||||
|
|
|
@ -21,6 +21,8 @@ angular.module("core-config-setup", ['angularFileUpload'])
|
||||||
|
|
||||||
{'id': 'registry-storage', 'title': 'Registry Storage'},
|
{'id': 'registry-storage', 'title': 'Registry Storage'},
|
||||||
|
|
||||||
|
{'id': 'time-machine', 'title': 'Time Machine'},
|
||||||
|
|
||||||
{'id': 'ssl', 'title': 'SSL certificate and key', 'condition': function(config) {
|
{'id': 'ssl', 'title': 'SSL certificate and key', 'condition': function(config) {
|
||||||
return config.PREFERRED_URL_SCHEME == 'https';
|
return config.PREFERRED_URL_SCHEME == 'https';
|
||||||
}},
|
}},
|
||||||
|
@ -826,7 +828,8 @@ angular.module("core-config-setup", ['angularFileUpload'])
|
||||||
'binding': '=binding',
|
'binding': '=binding',
|
||||||
'placeholder': '@placeholder',
|
'placeholder': '@placeholder',
|
||||||
'defaultValue': '@defaultValue',
|
'defaultValue': '@defaultValue',
|
||||||
'itemTitle': '@itemTitle'
|
'itemTitle': '@itemTitle',
|
||||||
|
'itemPattern': '@itemPattern'
|
||||||
},
|
},
|
||||||
controller: function($scope, $element) {
|
controller: function($scope, $element) {
|
||||||
$scope.removeItem = function(item) {
|
$scope.removeItem = function(item) {
|
||||||
|
@ -853,6 +856,20 @@ angular.module("core-config-setup", ['angularFileUpload'])
|
||||||
$scope.newItemName = null;
|
$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) {
|
$scope.$watch('binding', function(binding) {
|
||||||
if (!binding && $scope.defaultValue) {
|
if (!binding && $scope.defaultValue) {
|
||||||
$scope.binding = eval($scope.defaultValue);
|
$scope.binding = eval($scope.defaultValue);
|
||||||
|
|
104
static/js/directives/range-slider.js
Normal file
104
static/js/directives/range-slider.js
Normal 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();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
]);
|
|
@ -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>
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
|
@ -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>
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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 { ChannelIconComponent } from './directives/ui/channel-icon/channel-icon.component';
|
||||||
import { TagSigningDisplayComponent } from './directives/ui/tag-signing-display/tag-signing-display.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 { 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 { BuildServiceImpl } from './services/build/build.service.impl';
|
||||||
import { AvatarServiceImpl } from './services/avatar/avatar.service.impl';
|
import { AvatarServiceImpl } from './services/avatar/avatar.service.impl';
|
||||||
import { DockerfileServiceImpl } from './services/dockerfile/dockerfile.service.impl';
|
import { DockerfileServiceImpl } from './services/dockerfile/dockerfile.service.impl';
|
||||||
|
@ -48,6 +50,8 @@ import { QuayRequireDirective } from './directives/structural/quay-require/quay-
|
||||||
QuayRequireDirective,
|
QuayRequireDirective,
|
||||||
TagSigningDisplayComponent,
|
TagSigningDisplayComponent,
|
||||||
RepositorySigningConfigComponent,
|
RepositorySigningConfigComponent,
|
||||||
|
TimeMachineSettingsComponent,
|
||||||
|
DurationInputComponent,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
ViewArrayImpl,
|
ViewArrayImpl,
|
||||||
|
|
|
@ -119,6 +119,8 @@
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
<div class="delete-namespace-view" subscription-status="subscriptionStatus" organization="organization" namespace-title="organization"></div>
|
<div class="delete-namespace-view" subscription-status="subscriptionStatus" organization="organization" namespace-title="organization"></div>
|
||||||
|
|
||||||
|
<time-machine-settings organization="organization"></time-machine-settings>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Billing Information -->
|
<!-- Billing Information -->
|
||||||
|
|
|
@ -135,6 +135,8 @@
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
<div class="delete-namespace-view" subscription-status="subscriptionStatus" user="context.viewuser" namespace-title="account" quay-show="Config.AUTHENTICATION_TYPE == 'Database'"></div>
|
<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>
|
</div>
|
||||||
|
|
||||||
<!-- Billing Information -->
|
<!-- Billing Information -->
|
||||||
|
|
|
@ -97,3 +97,7 @@ class TestConfig(DefaultConfig):
|
||||||
|
|
||||||
FEATURE_APP_REGISTRY = True
|
FEATURE_APP_REGISTRY = True
|
||||||
FEATURE_TEAM_SYNCING = True
|
FEATURE_TEAM_SYNCING = True
|
||||||
|
FEATURE_CHANGE_TAG_EXPIRATION = True
|
||||||
|
|
||||||
|
TAG_EXPIRATION_OPTIONS = ['0s', '1s', '1d', '1w', '2w', '4w']
|
||||||
|
|
||||||
|
|
|
@ -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_USER_CREATION'] = config_obj.get('FEATURE_USER_CREATION', True)
|
||||||
config_obj['FEATURE_ANONYMOUS_ACCESS'] = config_obj.get('FEATURE_ANONYMOUS_ACCESS', 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_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.
|
# Default features that are off.
|
||||||
config_obj['FEATURE_MAILING'] = config_obj.get('FEATURE_MAILING', False)
|
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(
|
config_obj['SECURITY_SCANNER_ISSUER_NAME'] = config_obj.get(
|
||||||
'SECURITY_SCANNER_ISSUER_NAME', 'security_scanner')
|
'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.
|
# Default mail setings.
|
||||||
config_obj['MAIL_USE_TLS'] = config_obj.get('MAIL_USE_TLS', True)
|
config_obj['MAIL_USE_TLS'] = config_obj.get('MAIL_USE_TLS', True)
|
||||||
config_obj['MAIL_PORT'] = config_obj.get('MAIL_PORT', 587)
|
config_obj['MAIL_PORT'] = config_obj.get('MAIL_PORT', 587)
|
||||||
|
|
|
@ -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_gitlab_trigger import GitLabTriggerValidator
|
||||||
from util.config.validators.validate_github import GitHubLoginValidator, GitHubTriggerValidator
|
from util.config.validators.validate_github import GitHubLoginValidator, GitHubTriggerValidator
|
||||||
from util.config.validators.validate_oidc import OIDCLoginValidator
|
from util.config.validators.validate_oidc import OIDCLoginValidator
|
||||||
|
from util.config.validators.validate_timemachine import TimeMachineValidator
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
@ -53,6 +54,7 @@ VALIDATORS = {
|
||||||
SecurityScannerValidator.name: SecurityScannerValidator.validate,
|
SecurityScannerValidator.name: SecurityScannerValidator.validate,
|
||||||
BittorrentValidator.name: BittorrentValidator.validate,
|
BittorrentValidator.name: BittorrentValidator.validate,
|
||||||
OIDCLoginValidator.name: OIDCLoginValidator.validate,
|
OIDCLoginValidator.name: OIDCLoginValidator.validate,
|
||||||
|
TimeMachineValidator.name: TimeMachineValidator.validate,
|
||||||
}
|
}
|
||||||
|
|
||||||
def validate_service_for_config(service, config, password=None):
|
def validate_service_for_config(service, config, password=None):
|
||||||
|
|
23
util/config/validators/test/test_validate_timemachine.py
Normal file
23
util/config/validators/test/test_validate_timemachine.py
Normal 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)
|
25
util/config/validators/validate_timemachine.py
Normal file
25
util/config/validators/validate_timemachine.py
Normal 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)
|
|
@ -15,6 +15,7 @@ def convert_to_timedelta(time_val):
|
||||||
m Minutes '5m' -> 5 Minutes
|
m Minutes '5m' -> 5 Minutes
|
||||||
h Hours '24h' -> 24 Hours
|
h Hours '24h' -> 24 Hours
|
||||||
d Days '7d' -> 7 Days
|
d Days '7d' -> 7 Days
|
||||||
|
w Weeks '2w' -> 2 weeks
|
||||||
========= ======= ===================
|
========= ======= ===================
|
||||||
|
|
||||||
Examples::
|
Examples::
|
||||||
|
@ -37,5 +38,7 @@ def convert_to_timedelta(time_val):
|
||||||
return timedelta(hours=num)
|
return timedelta(hours=num)
|
||||||
elif time_val.endswith('d'):
|
elif time_val.endswith('d'):
|
||||||
return timedelta(days=num)
|
return timedelta(days=num)
|
||||||
|
elif time_val.endswith('w'):
|
||||||
|
return timedelta(days=num*7)
|
||||||
else:
|
else:
|
||||||
raise ValueError('Unknown suffix on timedelta: %s' % time_val)
|
raise ValueError('Unknown suffix on timedelta: %s' % time_val)
|
||||||
|
|
Reference in a new issue