diff --git a/static/js/core-config-setup.js b/static/js/core-config-setup.js
index c47ba2f77..ccc7317af 100644
--- a/static/js/core-config-setup.js
+++ b/static/js/core-config-setup.js
@@ -1246,5 +1246,69 @@ angular.module("core-config-setup", ['angularFileUpload'])
}
};
return directiveDefinitionObject;
+ })
+
+ .directive('configLicenseField', function () {
+ var directiveDefinitionObject = {
+ priority: 0,
+ templateUrl: '/static/directives/config/config-license-field.html',
+ replace: false,
+ transclude: false,
+ restrict: 'C',
+ scope: {
+ },
+ controller: function($scope, $element, ApiService) {
+ $scope.state = 'loading-license';
+ $scope.showingEditor = false;
+ $scope.requiredBox = '';
+
+ var loadLicense = function() {
+ ApiService.getLicense().then(function(resp) {
+ $scope.state = 'license-valid';
+ $scope.showingEditor = false;
+ $scope.licenseDecoded = resp['decoded'];
+ $scope.requiredBox = 'filled';
+ }, function(resp) {
+ $scope.licenseError = ApiService.getErrorMessage(resp);
+ $scope.state = 'license-error';
+ $scope.showingEditor = true;
+ $scope.requiredBox = '';
+ });
+ };
+
+ loadLicense();
+
+ $scope.showEditor = function($event) {
+ $event.preventDefault();
+ $event.stopPropagation();
+
+ $scope.showingEditor = true;
+ };
+
+ $scope.validateAndUpdate = function($event) {
+ $event.preventDefault();
+ $event.stopPropagation();
+
+ $scope.state = 'validating-license';
+
+ var data = {
+ 'license': $scope.licenseContents
+ };
+
+ ApiService.updateLicense(data).then(function(resp) {
+ $scope.state = 'license-valid';
+ $scope.showingEditor = false;
+ $scope.licenseDecoded = resp['decoded'];
+ $scope.requiredBox = 'filled';
+ }, function(resp) {
+ $scope.licenseError = ApiService.getErrorMessage(resp);
+ $scope.state = 'license-error';
+ $scope.showingEditor = true;
+ $scope.requiredBox = '';
+ });
+ };
+ }
+ };
+ return directiveDefinitionObject;
});
diff --git a/static/js/pages/setup.js b/static/js/pages/setup.js
index 0b5eecad7..3759a9bf2 100644
--- a/static/js/pages/setup.js
+++ b/static/js/pages/setup.js
@@ -107,7 +107,7 @@
'hasDatabaseSSLCert': false,
'licenseContents': '',
'licenseError': null,
- 'licenseDecoded': null,
+ 'licenseDecoded': null
};
$scope.$watch('currentStep', function(currentStep) {
diff --git a/test/test_api_security.py b/test/test_api_security.py
index 426e7ea20..c31e771af 100644
--- a/test/test_api_security.py
+++ b/test/test_api_security.py
@@ -51,8 +51,7 @@ from endpoints.api.superuser import (SuperUserLogs, SuperUserList, SuperUserMana
SuperUserOrganizationManagement, SuperUserOrganizationList,
SuperUserAggregateLogs, SuperUserServiceKeyManagement,
SuperUserServiceKey, SuperUserServiceKeyApproval,
- SuperUserTakeOwnership,)
-from endpoints.api.globalmessages import (GlobalUserMessage, GlobalUserMessages,)
+ SuperUserTakeOwnership, SuperUserMessages, SuperUserLicense)
from endpoints.api.secscan import RepositoryImageSecurity
from endpoints.api.manifest import RepositoryManifestLabels, ManageRepositoryManifestLabel
@@ -4158,6 +4157,37 @@ class TestSuperUserList(ApiTestCase):
self._run_test('GET', 200, 'devtable', None)
+class TestSuperUserLicense(ApiTestCase):
+ def setUp(self):
+ ApiTestCase.setUp(self)
+ self._set_url(SuperUserLicense)
+
+ def test_get_anonymous(self):
+ self._run_test('GET', 401, None, None)
+
+ def test_get_freshuser(self):
+ self._run_test('GET', 403, 'freshuser', None)
+
+ def test_get_reader(self):
+ self._run_test('GET', 403, 'reader', None)
+
+ def test_get_devtable(self):
+ self._run_test('GET', 400, 'devtable', None)
+
+
+ def test_put_anonymous(self):
+ self._run_test('PUT', 401, None, {})
+
+ def test_put_freshuser(self):
+ self._run_test('PUT', 403, 'freshuser', {'license': ''})
+
+ def test_put_reader(self):
+ self._run_test('PUT', 403, 'reader', {'license': ''})
+
+ def test_put_devtable(self):
+ self._run_test('PUT', 400, 'devtable', {'license': ''})
+
+
class TestSuperUserManagement(ApiTestCase):
def setUp(self):
ApiTestCase.setUp(self)
diff --git a/test/test_license.py b/test/test_license.py
index 802b036b7..9dc920d61 100644
--- a/test/test_license.py
+++ b/test/test_license.py
@@ -111,18 +111,19 @@ class TestLicense(unittest.TestCase):
self.assertFalse(license.is_expired)
def test_validate_basic_license(self):
- decoded = self.get_license(timedelta(days=30), entitlements={})
+ decoded = self.get_license(timedelta(days=30), service_end=timedelta(days=40),
+ duration='months', entitlements={})
decoded.validate({'DISTRIBUTED_STORAGE_CONFIG': [{}]})
def test_validate_storage_entitlement_valid(self):
- decoded = self.get_license(timedelta(days=30), entitlements={
+ decoded = self.get_license(timedelta(days=30), service_end=timedelta(days=40), entitlements={
'software.quay.regions': 2,
})
decoded.validate({'DISTRIBUTED_STORAGE_CONFIG': [{}]})
def test_validate_storage_entitlement_invalid(self):
- decoded = self.get_license(timedelta(days=30), entitlements={
+ decoded = self.get_license(timedelta(days=30), service_end=timedelta(days=40), entitlements={
'software.quay.regions': 1,
})
diff --git a/util/config/provider/baseprovider.py b/util/config/provider/baseprovider.py
index ad7768de9..f9a48c67a 100644
--- a/util/config/provider/baseprovider.py
+++ b/util/config/provider/baseprovider.py
@@ -102,22 +102,22 @@ class BaseProvider(object):
def _get_license_file(self):
""" Returns the contents of the license file. """
+ if not self.has_license_file():
+ msg = 'Could not find license file. Please make sure it is in your config volume.'
+ raise LicenseError(msg)
+
try:
return self.get_volume_file(LICENSE_FILENAME)
except IOError:
msg = 'Could not open license file. Please make sure it is in your config volume.'
raise LicenseError(msg)
- def validate_license(self, config):
- """ Validates that the configuration matches the license file (if any). """
- if not config.get('SETUP_COMPLETE', False):
- raise SetupIncompleteException()
-
+ def get_license(self):
+ """ Returns the decoded license, if any. """
with self._get_license_file() as f:
license_file_contents = f.read()
- self.license = decode_license(license_file_contents)
- self.license.validate(config)
+ return decode_license(license_file_contents)
def save_license(self, license_file_contents):
""" Saves the given contents as the license file. """
diff --git a/util/config/provider/testprovider.py b/util/config/provider/testprovider.py
index a1bce6d30..d08b13049 100644
--- a/util/config/provider/testprovider.py
+++ b/util/config/provider/testprovider.py
@@ -1,5 +1,5 @@
import json
-from StringIO import StringIO
+import io
from util.config.provider.baseprovider import BaseProvider
@@ -53,7 +53,7 @@ class TestConfigProvider(BaseProvider):
if filename in REAL_FILES:
return open(filename, mode=mode)
- return StringIO(self.files[filename])
+ return io.BytesIO(self.files[filename])
def requires_restart(self, app_config):
return False
diff --git a/util/license.py b/util/license.py
index 85a77ff3c..1fd9f9fb5 100644
--- a/util/license.py
+++ b/util/license.py
@@ -43,11 +43,26 @@ class LicenseValidationError(LicenseError):
def _get_date(decoded, field):
""" Retrieves the encoded date found at the given field under the decoded license block. """
date_str = decoded.get(field)
- if date_str:
- return parser.parse(date_str).replace(tzinfo=None)
+ return parser.parse(date_str).replace(tzinfo=None) if date_str else None
- return datetime.now() - timedelta(days=2)
+class LicenseExpirationDate(object):
+ def __init__(self, title, expiration_date, grace_period=None):
+ self.title = title
+ self.expiration_date = expiration_date
+ self.grace_period = grace_period or timedelta(seconds=0)
+
+ def check_expired(self, cutoff_date=None):
+ return self.expiration_and_grace <= (cutoff_date or datetime.now())
+
+ @property
+ def expiration_and_grace(self):
+ return self.expiration_date + self.grace_period
+
+ def __str__(self):
+ return 'License expiration "%s" date %s with grace %s: %s' % (self.title, self.expiration_date,
+ self.grace_period,
+ self.check_expired())
class License(object):
""" License represents a fully decoded and validated (but potentially expired) license. """
@@ -65,7 +80,8 @@ class License(object):
@property
def is_expired(self):
- return self._get_expired(datetime.now())
+ cutoff_date = datetime.now()
+ return bool([dt for dt in self._get_expiration_dates() if dt.check_expired(cutoff_date)])
def validate(self, config):
""" Validates the license and all its entitlements against the given config. """
@@ -81,47 +97,54 @@ class License(object):
max_regions)
raise LicenseValidationError(msg)
- def _get_expired(self, compare_date):
+ def _get_expiration_dates(self):
# Check if the license overall has expired.
expiration_date = _get_date(self.decoded, 'expirationDate')
- if expiration_date <= compare_date:
- logger.debug('License expired on %s', expiration_date)
- return True
+ if expiration_date is None:
+ yield LicenseExpirationDate('No valid Tectonic Account License', datetime.min)
+ return
+
+ yield LicenseExpirationDate('Tectonic Account License', expiration_date)
# Check for any QE subscriptions.
sub = self.subscription
if sub is None:
- return True
+ yield LicenseExpirationDate('No Quay Enterprise Subscription', datetime.min)
+ return
# Check for a trial-only license.
if sub.get('trialOnly', False):
trial_end_date = _get_date(sub, 'trialEnd')
- logger.debug('Trial-only license expires on %s', trial_end_date)
- return trial_end_date <= (compare_date - TRIAL_GRACE_PERIOD)
+ if trial_end_date is None:
+ yield LicenseExpirationDate('Invalid trial subscription', datetime.min)
+ else:
+ yield LicenseExpirationDate('Trial subscription', trial_end_date, TRIAL_GRACE_PERIOD)
+
+ return
# Check for a normal license that is in trial.
service_end_date = _get_date(sub, 'serviceEnd')
+ if service_end_date is None:
+ yield LicenseExpirationDate('No valid Quay Enterprise Subscription', datetime.min)
+ return
+
if sub.get('inTrial', False):
# If the subscription is in a trial, but not a trial only
# subscription, give 7 days after trial end to update license
# to one which has been paid (they've put in a credit card and it
# might auto convert, so we could assume it will auto-renew)
- logger.debug('In-trial license expires on %s', service_end_date)
- return service_end_date <= (compare_date - TRIAL_GRACE_PERIOD)
+ yield LicenseExpirationDate('In-trial subscription', service_end_date, TRIAL_GRACE_PERIOD)
# Otherwise, check the service expiration.
duration_period = sub.get('durationPeriod', 'months')
# If the subscription is monthly, give 3 months grace period
if duration_period == "months":
- logger.debug('Monthly license expires on %s', service_end_date)
- return service_end_date <= (compare_date - MONTHLY_GRACE_PERIOD)
+ yield LicenseExpirationDate('Monthly subscription', service_end_date, MONTHLY_GRACE_PERIOD)
if duration_period == "years":
- logger.debug('Yearly license expires on %s', service_end_date)
- return service_end_date <= (compare_date - YEARLY_GRACE_PERIOD)
+ yield LicenseExpirationDate('Yearly subscription', service_end_date, YEARLY_GRACE_PERIOD)
- return True
_PROD_LICENSE_PUBLIC_KEY_DATA = """