Merge pull request #2516 from coreos-inc/time-machine
Time machine configuration in Quay
This commit is contained in:
commit
cd8df95132
28 changed files with 481 additions and 16 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',
|
||||
'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
|
||||
|
|
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,
|
||||
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()
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -379,6 +379,7 @@ class Repository(RepositoryParamResource):
|
|||
'is_starred': is_starred,
|
||||
'status_token': repo.badge_token if not is_public else '',
|
||||
'trust_enabled': bool(features.SIGNING) and repo.trust_enabled,
|
||||
'tag_expiration_s': repo.namespace_user.removed_tag_expiration_s,
|
||||
}
|
||||
|
||||
if stats is not None:
|
||||
|
|
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_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):
|
||||
|
|
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>
|
||||
<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>
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -122,7 +122,7 @@
|
|||
|
||||
<div class="tag-specific-images-view" tag="deleteTagInfo.tag" repository="repository"
|
||||
image-loader="imageLoader" style="margin-top: 20px">
|
||||
The following images and any other images not referenced by a tag will be deleted:
|
||||
The following images and any other images not referenced by a tag will be <span ng-if="repository.tag_expiration_s == 0">deleted</span><span ng-if="repository.tag_expiration_s != 0">unavailable and deleted in {{ getFormattedTimespan(repository.tag_expiration_s) }}</span>:
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
@ -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);
|
||||
|
|
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();
|
||||
}
|
||||
}
|
|
@ -153,6 +153,13 @@ angular.module('quay').directive('tagOperationsDialog', function () {
|
|||
}, errorHandler);
|
||||
};
|
||||
|
||||
$scope.getFormattedTimespan = function(seconds) {
|
||||
if (!seconds) {
|
||||
return null;
|
||||
}
|
||||
return moment.duration(seconds, "seconds").humanize();
|
||||
};
|
||||
|
||||
$scope.editLabels = function(info, callback) {
|
||||
var actions = [];
|
||||
var existingMutableLabels = {};
|
||||
|
|
|
@ -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 { 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,
|
||||
|
|
|
@ -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 -->
|
||||
|
|
|
@ -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 -->
|
||||
|
|
|
@ -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']
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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):
|
||||
|
|
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
|
||||
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)
|
||||
|
|
Reference in a new issue