diff --git a/endpoints/api/suconfig.py b/endpoints/api/suconfig.py
index 3da059290..9235e8ef3 100644
--- a/endpoints/api/suconfig.py
+++ b/endpoints/api/suconfig.py
@@ -19,7 +19,7 @@ from data.database import User
from util.config.configutil import add_enterprise_config_defaults
from util.config.database import sync_database_with_config
from util.config.validator import validate_service_for_config, CONFIG_FILENAMES
-from util.license import decode_license, LicenseError
+from util.license import decode_license, LicenseDecodeError
from data.runmigration import run_alembic_migration
from data.users import get_federated_service_name, get_users_handler
@@ -283,16 +283,17 @@ class SuperUserSetAndValidateLicense(ApiResource):
license_contents = request.get_json()['license']
try:
decoded_license = decode_license(license_contents)
- except LicenseError as le:
+ except LicenseDecodeError as le:
raise InvalidRequest(le.message)
- if decoded_license.is_expired:
- raise InvalidRequest('License has expired')
+ statuses = decoded_license.validate({})
+ all_met = all(status.is_met() for status in statuses)
+ if all_met:
+ config_provider.save_license(license_contents)
- config_provider.save_license(license_contents)
return {
- 'decoded': decoded_license.subscription,
- 'success': True
+ 'status': [status.as_dict(for_private=True) for status in statuses],
+ 'success': all_met,
}
diff --git a/endpoints/api/superuser.py b/endpoints/api/superuser.py
index d1fe52518..7344da266 100644
--- a/endpoints/api/superuser.py
+++ b/endpoints/api/superuser.py
@@ -11,7 +11,7 @@ from flask import request, make_response, jsonify
import features
-from app import app, avatar, superusers, authentication, config_provider
+from app import app, avatar, superusers, authentication, config_provider, license_validator
from auth import scopes
from auth.auth_context import get_authenticated_user
from auth.permissions import SuperUserPermission
@@ -23,7 +23,7 @@ from endpoints.api.logs import get_logs, get_aggregate_logs
from data import model
from data.database import ServiceKeyApprovalType
from util.useremails import send_confirmation_email, send_recovery_email
-from util.license import decode_license, LicenseError
+from util.license import decode_license, LicenseDecodeError
logger = logging.getLogger(__name__)
@@ -851,15 +851,15 @@ class SuperUserLicense(ApiResource):
if SuperUserPermission().can():
try:
decoded_license = config_provider.get_license()
- except LicenseError as le:
+ except LicenseDecodeError as le:
raise InvalidRequest(le.message)
- if decoded_license.is_expired:
- raise InvalidRequest('License has expired')
+ statuses = decoded_license.validate(app.config)
+ all_met = all(status.is_met() for status in statuses)
return {
- 'decoded': decoded_license.subscription,
- 'success': True
+ 'status': [status.as_dict(for_private=True) for status in statuses],
+ 'success': all_met,
}
abort(403)
@@ -875,16 +875,19 @@ class SuperUserLicense(ApiResource):
license_contents = request.get_json()['license']
try:
decoded_license = decode_license(license_contents)
- except LicenseError as le:
+ except LicenseDecodeError as le:
raise InvalidRequest(le.message)
- if decoded_license.is_expired:
- raise InvalidRequest('License has expired')
+ statuses = decoded_license.validate(app.config)
+ all_met = all(status.is_met() for status in statuses)
+ if all_met:
+ # Save the license and update the license check thread.
+ config_provider.save_license(license_contents)
+ license_validator.compute_license_sufficiency()
- config_provider.save_license(license_contents)
return {
- 'decoded': decoded_license.subscription,
- 'success': True
+ 'status': [status.as_dict(for_private=True) for status in statuses],
+ 'success': all_met,
}
abort(403)
diff --git a/static/css/core-ui.css b/static/css/core-ui.css
index bd482b37e..5ce33c73b 100644
--- a/static/css/core-ui.css
+++ b/static/css/core-ui.css
@@ -567,6 +567,11 @@ a:focus {
margin-right: 4px;
}
+.config-license-field-element .required {
+ background-color: #f5f5f5;
+ color: #333;
+}
+
.config-license-field-element textarea {
padding: 10px;
margin-bottom: 10px;
@@ -577,9 +582,8 @@ a:focus {
margin-bottom: 26px;
}
-.config-license-field-element table td:first-child {
- width: 150px;
- font-weight: bold;
+.config-license-field-element table {
+ margin-top: 20px;
}
.config-license-field-element .fa {
@@ -594,6 +598,10 @@ a:focus {
color: #D64456;
}
+.config-license-field-element li {
+ padding: 4px;
+}
+
.co-checkbox {
position: relative;
}
diff --git a/static/css/pages/setup.css b/static/css/pages/setup.css
index 2ca87dbdc..2cdeb027b 100644
--- a/static/css/pages/setup.css
+++ b/static/css/pages/setup.css
@@ -36,6 +36,10 @@
margin-bottom: 16px;
}
+.initial-setup-modal .config-license-field {
+ margin-top: 30px;
+}
+
.initial-setup-modal .license-valid .fa {
margin-right: 6px;
}
@@ -43,14 +47,3 @@
.initial-setup-modal .license-valid table {
margin-top: 40px;
}
-
-.initial-setup-modal .license-valid table td {
- border: 0px;
- padding: 4px;
-}
-
-.initial-setup-modal .license-valid table td:first-child {
- font-weight: bold;
- max-width: 100px;
- padding-right: 20px;
-}
diff --git a/static/directives/config/config-license-field.html b/static/directives/config/config-license-field.html
index 9eb688156..f414f2aa6 100644
--- a/static/directives/config/config-license-field.html
+++ b/static/directives/config/config-license-field.html
@@ -3,19 +3,54 @@
config if the license is invalid (since this box will be empty and therefore "required") -->
-
+
-
+
License Valid
- Product: {{ licenseDecoded.publicProductName || licenseDecoded.productName }}
- Plan: {{ licenseDecoded.publicPlanName || licenseDecoded.planName }}
+
+ Requirement
+ Required Count
+ Subscription
+ Subscription Count
+ Expiration Date
+
+
+ {{ requirementTitles[status.requirement.name] }}
+ {{ status.requirement.count }}
+ {{ status.entitlement.product_name }}
+ {{ status.entitlement.count }}
+
+
-
+
Validation Failed
-
{{ licenseError }}
+
{{ licenseError }}
+
+ The following errors were found:
+
+
+
+
+
+ {{ requirementTitles[status.requirement.name] }} : {{ status.requirement.count }}
are is required: License provides {{ status.entitlement.count }}
+
+
+
+
+ {{ requirementTitles[status.requirement.name] }} : License is missing requirement
+
+
+
+
+ {{ requirementTitles[status.requirement.name] }} : Requirement expired on {{ status.entitlement.expiration.expiration_date }}
+
+
+
+
+
Update License
@@ -28,12 +63,12 @@
+ ng-readonly="state == LicenseStates.validating">
-
Update License
-
diff --git a/static/js/core-config-setup.js b/static/js/core-config-setup.js
index 19977d359..3cbafb175 100644
--- a/static/js/core-config-setup.js
+++ b/static/js/core-config-setup.js
@@ -1256,24 +1256,52 @@ angular.module("core-config-setup", ['angularFileUpload'])
transclude: false,
restrict: 'C',
scope: {
+ 'isValid': '=?isValid',
+ 'forSetup': '@forSetup'
},
controller: function($scope, $element, ApiService, UserService) {
- $scope.state = 'loading-license';
- $scope.showingEditor = false;
+ $scope.LicenseStates = {
+ none: 'no-license',
+ loading: 'license-loading',
+ valid: 'license-valid',
+ invalid: 'license-error',
+ validating: 'validating-license'
+ };
+
+ $scope.state = $scope.forSetup == 'true' ? $scope.LicenseStates.none : $scope.LicenseStates.loading;
+ $scope.showingEditor = $scope.forSetup == 'true';
$scope.requiredBox = '';
+ $scope.requirementTitles = {
+ 'software.quay': 'Quay Enterprise',
+ 'software.quay.regions': 'Distributed Storage Regions'
+ };
+
+ var handleLicenseSuccess = function(resp) {
+ $scope.state = resp['success'] ? $scope.LicenseStates.valid : $scope.LicenseStates.invalid;
+ $scope.requiredBox = resp['success'] ? 'filled' : '';
+ $scope.showingEditor = !resp['success'];
+ $scope.licenseStatus = resp['status'];
+ $scope.licenseError = null;
+ $scope.isValid = resp['success'];
+ };
+
+ var handleLicenseError = function(resp) {
+ $scope.licenseError = ApiService.getErrorMessage(resp);
+ $scope.licenseStatus = null;
+ $scope.state = 'license-error';
+ $scope.showingEditor = true;
+ $scope.requiredBox = '';
+ $scope.isValid = false;
+ };
+
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 = '';
- });
+ if ($scope.forSetup == 'true') {
+ $scope.state = $scope.LicenseStates.none;
+ return;
+ }
+
+ ApiService.getLicense().then(handleLicenseSuccess, handleLicenseError);
};
UserService.updateUserIn($scope, function(user) {
@@ -1293,23 +1321,17 @@ angular.module("core-config-setup", ['angularFileUpload'])
$event.preventDefault();
$event.stopPropagation();
- $scope.state = 'validating-license';
+ $scope.state = $scope.LicenseStates.validating;
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 = '';
- });
+ if ($scope.forSetup == 'true') {
+ ApiService.suSetAndValidateLicense(data).then(handleLicenseSuccess, handleLicenseError);
+ } else {
+ ApiService.updateLicense(data).then(handleLicenseSuccess, handleLicenseError);
+ }
};
}
};
diff --git a/static/js/pages/setup.js b/static/js/pages/setup.js
index 3759a9bf2..ab1938fe1 100644
--- a/static/js/pages/setup.js
+++ b/static/js/pages/setup.js
@@ -40,12 +40,6 @@
// License is being uploaded.
'UPLOAD_LICENSE': 'upload-license',
- // License is being validated.
- 'VALIDATING_LICENSE': 'upload-license-validating',
-
- // License is validated.
- 'VALIDATED_LICENSE': 'upload-license-validated',
-
// DB is being configured.
'CONFIG_DB': 'config-db',
@@ -105,9 +99,7 @@
$scope.currentState = {
'hasDatabaseSSLCert': false,
- 'licenseContents': '',
- 'licenseError': null,
- 'licenseDecoded': null
+ 'licenseValid': false
};
$scope.$watch('currentStep', function(currentStep) {
@@ -144,27 +136,6 @@
}
});
- $scope.validateLicense = function() {
- $scope.currentStep = $scope.States.VALIDATING_LICENSE;
-
- var data = {
- 'license': $scope.currentState.licenseContents
- };
-
- ApiService.suSetAndValidateLicense(data).then(function(resp) {
- $scope.currentStep = $scope.States.VALIDATED_LICENSE;
-
- $scope.currentState.licenseError = null;
- $scope.currentState.licenseDecoded = resp['decoded'];
- }, function(resp) {
- $scope.currentStep = $scope.States.UPLOAD_LICENSE;
-
- $scope.currentState.licenseError = ApiService.getErrorMessage(resp);
- $scope.currentState.licenseContents = '';
- $scope.currentState.licenseDecoded = null;
- });
- };
-
$scope.restartContainer = function(state) {
$scope.currentStep = state;
ContainerService.restartContainer(function() {
diff --git a/static/partials/setup.html b/static/partials/setup.html
index e238ab846..a59c506e6 100644
--- a/static/partials/setup.html
+++ b/static/partials/setup.html
@@ -130,21 +130,9 @@
The container must be restarted to apply the configuration changes.
-
-
-
License Validated
- Your license has been validated and saved. Please press "Next" to continue setup of your Quay Enterprise installation.
-
- Product: {{ currentState.licenseDecoded.publicProductName || currentState.licenseDecoded.productName }}
- Plan: {{ currentState.licenseDecoded.publicPlanName || currentState.licenseDecoded.planName }}
-
-
-
-
-
+
+
Quay Enterprise License
@@ -152,13 +140,7 @@
Please provide your Quay Enterprise License. It can be found under the "Raw Format" tab
of your Quay Enterprise subscription in the
Tectonic Account .
-
-
-
Validation Failed
- {{ currentState.licenseError }}
- Please try copying your license from the Tectonic Account again.
-
+
@@ -259,26 +241,12 @@
Database Validation Issue: {{ errors.DatabaseValidationError }}
-
+
-
-
-
diff --git a/test/test_license.py b/test/test_license.py
index 9dc920d61..c10f6a23e 100644
--- a/test/test_license.py
+++ b/test/test_license.py
@@ -9,9 +9,13 @@ from Crypto.PublicKey import RSA
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives.serialization import load_der_public_key
-from util.license import decode_license, LICENSE_PRODUCT_NAME, LicenseValidationError
+from util.license import (decode_license, LicenseDecodeError, ExpirationType,
+ MONTHLY_GRACE_PERIOD, YEARLY_GRACE_PERIOD, TRIAL_GRACE_PERIOD)
+def get_date(delta):
+ return str(datetime.now() + delta)
+
class TestLicense(unittest.TestCase):
def keys(self):
with open('test/data/test.pem') as f:
@@ -21,12 +25,12 @@ class TestLicense(unittest.TestCase):
backend=default_backend())
return (public_key, private_key)
- def create_license(self, license_data):
+ def create_license(self, license_data, keys=None):
jwt_data = {
'license': json.dumps(license_data),
}
- (public_key, private_key) = self.keys()
+ (public_key, private_key) = keys or self.keys()
# Encode the license with the JWT key.
encoded = jwt.encode(jwt_data, private_key, algorithm='RS256')
@@ -34,102 +38,435 @@ class TestLicense(unittest.TestCase):
# Decode it into a license object.
return decode_license(encoded, public_key_instance=public_key)
- def get_license(self, expiration_delta=None, **kwargs):
- license_data = {
- 'expirationDate': str(datetime.now() + expiration_delta),
+ def test_license_decodeerror_invalid(self):
+ with self.assertRaises(LicenseDecodeError):
+ decode_license('some random stuff')
+
+ def test_license_decodeerror_badkey(self):
+ (_, private_key) = self.keys()
+ jwt_data = {
+ 'license': json.dumps({}),
}
- if kwargs:
- sub = {
- 'productName': LICENSE_PRODUCT_NAME,
- }
+ encoded_stuff = jwt.encode(jwt_data, private_key, algorithm='RS256')
+ with self.assertRaises(LicenseDecodeError):
+ # Note that since we don't give a key here, the prod one will be used, and it should fail.
+ decode_license(encoded_stuff)
- sub['trialOnly'] = kwargs.get('trial_only', False)
- sub['inTrial'] = kwargs.get('in_trial', False)
- sub['entitlements'] = kwargs.get('entitlements', [])
+ def assertValid(self, license, config=None):
+ results = license.validate(config or {})
+ is_met = all([r.is_met() for r in results])
+ self.assertTrue(is_met, [r for r in results if not r.is_met()])
- if 'trial_end' in kwargs:
- sub['trialEnd'] = str(datetime.now() + kwargs['trial_end'])
+ def assertNotValid(self, license, config=None, requirement=None, expired=None):
+ results = license.validate(config or {})
+ is_met = all([r.is_met() for r in results])
+ self.assertFalse(is_met)
- if 'service_end' in kwargs:
- sub['serviceEnd'] = str(datetime.now() + kwargs['service_end'])
+ invalid_results = [r for r in results if not r.is_met()]
+ if requirement is not None:
+ self.assertEquals(invalid_results[0].requirement.name, requirement)
- if 'duration' in kwargs:
- sub['durationPeriod'] = kwargs['duration']
+ if expired is not None:
+ self.assertEquals(invalid_results[0].entitlement.expiration.expiration_type, expired)
- license_data['subscriptions'] = {'somesub': sub}
-
- decoded_license = self.create_license(license_data)
- return decoded_license
-
- def test_license_itself_expired(self):
- # License is expired.
- license = self.get_license(timedelta(days=-30))
-
- def test_no_qe_subscription(self):
- # License is not expired, but there is no QE sub, so not valid.
- license = self.get_license(timedelta(days=30))
-
- def test_trial_withingrace(self):
- license = self.get_license(timedelta(days=30), trial_only=True, trial_end=timedelta(days=-1))
- self.assertFalse(license.is_expired)
-
- def test_trial_outsidegrace(self):
- license = self.get_license(timedelta(days=30), trial_only=True, trial_end=timedelta(days=-10))
- self.assertTrue(license.is_expired)
-
- def test_trial_intrial_withingrace(self):
- license = self.get_license(timedelta(days=30), in_trial=True, service_end=timedelta(days=-1))
- self.assertFalse(license.is_expired)
-
- def test_trial_intrial_outsidegrace(self):
- license = self.get_license(timedelta(days=30), in_trial=True, service_end=timedelta(days=-10))
- self.assertTrue(license.is_expired)
-
- def test_monthly_license_valid(self):
- license = self.get_license(timedelta(days=30), service_end=timedelta(days=10), duration='months')
- self.assertFalse(license.is_expired)
-
- def test_monthly_license_withingrace(self):
- license = self.get_license(timedelta(days=30), service_end=timedelta(days=-10), duration='months')
- self.assertFalse(license.is_expired)
-
- def test_monthly_license_outsidegrace(self):
- license = self.get_license(timedelta(days=30), service_end=timedelta(days=-40), duration='months')
- self.assertTrue(license.is_expired)
-
- def test_yearly_license_withingrace(self):
- license = self.get_license(timedelta(days=30), service_end=timedelta(days=-40), duration='years')
- self.assertFalse(license.is_expired)
-
- def test_yearly_license_outsidegrace(self):
- license = self.get_license(timedelta(days=30), service_end=timedelta(days=-100), duration='years')
- self.assertTrue(license.is_expired)
-
- def test_valid_license(self):
- license = self.get_license(timedelta(days=300), service_end=timedelta(days=40), duration='years')
- self.assertFalse(license.is_expired)
-
- def test_validate_basic_license(self):
- 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), service_end=timedelta(days=40), entitlements={
- 'software.quay.regions': 2,
+ def test_missing_subscriptions(self):
+ license = self.create_license({
+ "expirationDate": get_date(timedelta(days=10)),
})
- decoded.validate({'DISTRIBUTED_STORAGE_CONFIG': [{}]})
+ self.assertNotValid(license, requirement='software.quay')
- def test_validate_storage_entitlement_invalid(self):
- decoded = self.get_license(timedelta(days=30), service_end=timedelta(days=40), entitlements={
- 'software.quay.regions': 1,
+ def test_empty_subscriptions(self):
+ license = self.create_license({
+ "expirationDate": get_date(timedelta(days=10)),
+ "subscriptions": {},
})
- with self.assertRaises(LicenseValidationError):
- decoded.validate({'DISTRIBUTED_STORAGE_CONFIG': [{}, {}]})
+ self.assertNotValid(license, requirement='software.quay')
+ def test_missing_quay_entitlement(self):
+ license = self.create_license({
+ "expirationDate": get_date(timedelta(days=10)),
+ "subscriptions": {
+ "somesub": {
+ "serviceEnd": get_date(timedelta(days=10)),
+ "entitlements": {
+ "software.quay.regions": 0,
+ },
+ },
+ },
+ })
+
+ self.assertNotValid(license, requirement='software.quay')
+
+ def test_valid_quay_entitlement(self):
+ license = self.create_license({
+ "expirationDate": get_date(timedelta(days=10)),
+ "subscriptions": {
+ "somesub": {
+ "serviceEnd": get_date(timedelta(days=10)),
+ "entitlements": {
+ "software.quay": 1,
+ "software.quay.regions": 1,
+ },
+ },
+ },
+ })
+
+ self.assertValid(license)
+
+ def test_missing_expiration(self):
+ license = self.create_license({
+ "subscriptions": {
+ "somesub": {
+ "serviceEnd": get_date(timedelta(days=10)),
+ "entitlements": {
+ "software.quay": 1,
+ "software.quay.regions": 1,
+ },
+ },
+ },
+ })
+
+ self.assertNotValid(license, expired=ExpirationType.license_wide)
+
+ def test_expired_license(self):
+ license = self.create_license({
+ "expirationDate": get_date(timedelta(days=-10)),
+ "subscriptions": {
+ "somesub": {
+ "serviceEnd": get_date(timedelta(days=10)),
+ "entitlements": {
+ "software.quay": 1,
+ "software.quay.regions": 1,
+ },
+ },
+ },
+ })
+
+ self.assertNotValid(license, expired=ExpirationType.license_wide)
+
+ def test_expired_sub_implicit_monthly_withingrace(self):
+ license = self.create_license({
+ "expirationDate": get_date(timedelta(days=10)),
+ "subscriptions": {
+ "somesub": {
+ "serviceEnd": get_date(MONTHLY_GRACE_PERIOD * -1 + timedelta(days=1)),
+ "entitlements": {
+ "software.quay": 1,
+ "software.quay.regions": 1,
+ },
+ },
+ },
+ })
+
+ self.assertValid(license)
+
+ def test_expired_sub_monthly_withingrace(self):
+ license = self.create_license({
+ "expirationDate": get_date(timedelta(days=10)),
+ "subscriptions": {
+ "somesub": {
+ "serviceEnd": get_date(MONTHLY_GRACE_PERIOD * -1 + timedelta(days=1)),
+ "durationPeriod": "monthly",
+ "entitlements": {
+ "software.quay": 1,
+ "software.quay.regions": 1,
+ },
+ },
+ },
+ })
+
+ self.assertValid(license)
+
+ def test_expired_sub_monthly_outsidegrace(self):
+ license = self.create_license({
+ "expirationDate": get_date(timedelta(days=10)),
+ "subscriptions": {
+ "somesub": {
+ "serviceEnd": get_date(MONTHLY_GRACE_PERIOD * -1 + timedelta(days=-1)),
+ "durationPeriod": "monthly",
+ "entitlements": {
+ "software.quay": 1,
+ "software.quay.regions": 1,
+ },
+ },
+ },
+ })
+
+ self.assertNotValid(license, expired=ExpirationType.monthly)
+
+ def test_expired_sub_yearly_withingrace(self):
+ license = self.create_license({
+ "expirationDate": get_date(timedelta(days=10)),
+ "subscriptions": {
+ "somesub": {
+ "serviceEnd": get_date(YEARLY_GRACE_PERIOD * -1 + timedelta(days=1)),
+ "durationPeriod": "yearly",
+ "entitlements": {
+ "software.quay": 1,
+ "software.quay.regions": 1,
+ },
+ },
+ },
+ })
+
+ self.assertValid(license)
+
+ def test_expired_sub_yearly_outsidegrace(self):
+ license = self.create_license({
+ "expirationDate": get_date(timedelta(days=10)),
+ "subscriptions": {
+ "somesub": {
+ "serviceEnd": get_date(YEARLY_GRACE_PERIOD * -1 + timedelta(days=-1)),
+ "durationPeriod": "yearly",
+ "entitlements": {
+ "software.quay": 1,
+ "software.quay.regions": 1,
+ },
+ },
+ },
+ })
+
+ self.assertNotValid(license, expired=ExpirationType.yearly)
+
+ def test_expired_sub_intrial_withingrace(self):
+ license = self.create_license({
+ "expirationDate": get_date(timedelta(days=10)),
+ "subscriptions": {
+ "somesub": {
+ "serviceEnd": get_date(TRIAL_GRACE_PERIOD * -1 + timedelta(days=1)),
+ "inTrial": True,
+ "entitlements": {
+ "software.quay": 1,
+ "software.quay.regions": 1,
+ },
+ },
+ },
+ })
+
+ self.assertValid(license)
+
+ def test_expired_sub_intrial_outsidegrace(self):
+ license = self.create_license({
+ "expirationDate": get_date(timedelta(days=10)),
+ "subscriptions": {
+ "somesub": {
+ "serviceEnd": get_date(TRIAL_GRACE_PERIOD * -1 + timedelta(days=-1)),
+ "inTrial": True,
+ "entitlements": {
+ "software.quay": 1,
+ "software.quay.regions": 1,
+ },
+ },
+ },
+ })
+
+ self.assertNotValid(license, expired=ExpirationType.in_trial)
+
+ def test_expired_sub_trialonly_withingrace(self):
+ license = self.create_license({
+ "expirationDate": get_date(timedelta(days=10)),
+ "subscriptions": {
+ "somesub": {
+ "trialEnd": get_date(TRIAL_GRACE_PERIOD * -1 + timedelta(days=1)),
+ "trialOnly": True,
+ "entitlements": {
+ "software.quay": 1,
+ "software.quay.regions": 1,
+ },
+ },
+ },
+ })
+
+ self.assertValid(license)
+
+ def test_expired_sub_trialonly_outsidegrace(self):
+ license = self.create_license({
+ "expirationDate": get_date(timedelta(days=10)),
+ "subscriptions": {
+ "somesub": {
+ "trialEnd": get_date(TRIAL_GRACE_PERIOD * -1 + timedelta(days=-1)),
+ "trialOnly": True,
+ "entitlements": {
+ "software.quay": 1,
+ "software.quay.regions": 1,
+ },
+ },
+ },
+ })
+
+ self.assertNotValid(license, expired=ExpirationType.trial_only)
+
+ def test_valid_quay_entitlement_regions(self):
+ license = self.create_license({
+ "expirationDate": get_date(timedelta(days=10)),
+ "subscriptions": {
+ "somesub": {
+ "serviceEnd": get_date(timedelta(days=10)),
+ "entitlements": {
+ "software.quay": 1,
+ "software.quay.regions": 1,
+ },
+ },
+ },
+ })
+
+ config = {
+ 'DISTRIBUTED_STORAGE_CONFIG': [
+ {'name': 'first'},
+ ],
+ }
+
+ self.assertValid(license, config=config)
+
+ def test_invalid_quay_entitlement_regions(self):
+ license = self.create_license({
+ "expirationDate": get_date(timedelta(days=10)),
+ "subscriptions": {
+ "somesub": {
+ "serviceEnd": get_date(timedelta(days=10)),
+ "entitlements": {
+ "software.quay": 1,
+ "software.quay.regions": 1,
+ },
+ },
+ },
+ })
+
+ config = {
+ 'DISTRIBUTED_STORAGE_CONFIG': [
+ {'name': 'first'},
+ {'name': 'second'},
+ ],
+ }
+
+ self.assertNotValid(license, config=config, requirement='software.quay.regions')
+
+ def test_valid_regions_across_multiple_sub(self):
+ license = self.create_license({
+ "expirationDate": get_date(timedelta(days=10)),
+ "subscriptions": {
+ "somesub": {
+ "serviceEnd": get_date(timedelta(days=10)),
+ "entitlements": {
+ "software.quay": 1,
+ "software.quay.regions": 1,
+ },
+ },
+ "anothersub": {
+ "serviceEnd": get_date(timedelta(days=20)),
+ "entitlements": {
+ "software.quay.regions": 5,
+ },
+ },
+ },
+ })
+
+ config = {
+ 'DISTRIBUTED_STORAGE_CONFIG': [
+ {'name': 'first'},
+ {'name': 'second'},
+ ],
+ }
+
+ self.assertValid(license, config=config)
+
+ def test_valid_regions_across_multiple_sub_one_expired(self):
+ # Setup a license with one sub having too few regions, and another having enough, but it is
+ # expired.
+ license = self.create_license({
+ "expirationDate": get_date(timedelta(days=10)),
+ "subscriptions": {
+ "somesub": {
+ "serviceEnd": get_date(timedelta(days=10)),
+ "entitlements": {
+ "software.quay": 1,
+ "software.quay.regions": 1,
+ },
+ },
+ "anothersub": {
+ "trialEnd": get_date(TRIAL_GRACE_PERIOD * -1 + timedelta(days=-1)),
+ "trialOnly": True,
+ "entitlements": {
+ "software.quay.regions": 5,
+ },
+ },
+ },
+ })
+
+ config = {
+ 'DISTRIBUTED_STORAGE_CONFIG': [
+ {'name': 'first'},
+ {'name': 'second'},
+ ],
+ }
+
+ self.assertNotValid(license, config=config, requirement='software.quay.regions',
+ expired=ExpirationType.trial_only)
+
+ def test_valid_regions_across_multiple_sub_one_expired(self):
+ license = self.create_license({
+ "expirationDate": get_date(timedelta(days=10)),
+ "subscriptions": {
+ "somesub": {
+ "trialEnd": get_date(TRIAL_GRACE_PERIOD * -1 + timedelta(days=-1)),
+ "trialOnly": True,
+ "entitlements": {
+ "software.quay": 1,
+ "software.quay.regions": 3,
+ },
+ },
+ "anothersub": {
+ "serviceEnd": get_date(timedelta(days=20)),
+ "entitlements": {
+ "software.quay": 1,
+ "software.quay.regions": 5,
+ },
+ },
+ },
+ })
+
+ config = {
+ 'DISTRIBUTED_STORAGE_CONFIG': [
+ {'name': 'first'},
+ {'name': 'second'},
+ ],
+ }
+
+ self.assertValid(license, config=config)
+
+ def test_quay_is_under_expired_sub(self):
+ license = self.create_license({
+ "expirationDate": get_date(timedelta(days=10)),
+ "subscriptions": {
+ "somesub": {
+ "trialEnd": get_date(TRIAL_GRACE_PERIOD * -1 + timedelta(days=-1)),
+ "trialOnly": True,
+ "entitlements": {
+ "software.quay": 1,
+ "software.quay.regions": 3,
+ },
+ },
+ "anothersub": {
+ "serviceEnd": get_date(timedelta(days=20)),
+ "entitlements": {
+ "software.quay.regions": 5,
+ },
+ },
+ },
+ })
+
+ config = {
+ 'DISTRIBUTED_STORAGE_CONFIG': [
+ {'name': 'first'},
+ {'name': 'second'},
+ ],
+ }
+
+ self.assertNotValid(license, config=config, expired=ExpirationType.trial_only,
+ requirement='software.quay')
if __name__ == '__main__':
unittest.main()
diff --git a/util/config/provider/baseprovider.py b/util/config/provider/baseprovider.py
index f9a48c67a..c833c2323 100644
--- a/util/config/provider/baseprovider.py
+++ b/util/config/provider/baseprovider.py
@@ -1,7 +1,7 @@
import logging
import yaml
-from util.license import LICENSE_FILENAME, LicenseError, decode_license
+from util.license import LICENSE_FILENAME, LicenseDecodeError, decode_license
logger = logging.getLogger(__name__)
@@ -104,13 +104,13 @@ class BaseProvider(object):
""" 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)
+ raise LicenseDecodeError(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)
+ raise LicenseDecodeError(msg)
def get_license(self):
""" Returns the decoded license, if any. """
diff --git a/util/config/provider/testprovider.py b/util/config/provider/testprovider.py
index 6f9400b50..ecf2376e9 100644
--- a/util/config/provider/testprovider.py
+++ b/util/config/provider/testprovider.py
@@ -1,21 +1,22 @@
import json
import io
+from datetime import datetime, timedelta
from util.config.provider.baseprovider import BaseProvider
+from util.license import (EntitlementValidationResult, Entitlement, Expiration, ExpirationType,
+ EntitlementRequirement)
REAL_FILES = ['test/data/signing-private.gpg', 'test/data/signing-public.gpg']
class TestLicense(object):
- @property
- def subscription(self):
- return {}
-
- @property
- def is_expired(self):
- return False
+ def validate_entitlement_requirement(self, entitlement_req, check_time):
+ expiration = Expiration(ExpirationType.license_wide, datetime.now() + timedelta(days=31))
+ entitlement = Entitlement('fake', 0, 'someprod', expiration)
+ fakereq = EntitlementRequirement('fake', 0)
+ return EntitlementValidationResult(fakereq, datetime.now(), entitlement)
def validate(self, config):
- pass
+ return [self.validate_entitlement_requirement(None, None)]
class TestConfigProvider(BaseProvider):
""" Implementation of the config provider for testing. Everything is kept in-memory instead on
diff --git a/util/license.py b/util/license.py
index 6ffa7d40f..663349a31 100644
--- a/util/license.py
+++ b/util/license.py
@@ -6,6 +6,9 @@ import time
from ctypes import c_bool
from datetime import datetime, timedelta
from threading import Thread
+from functools import total_ordering
+from enum import Enum, IntEnum
+from collections import namedtuple
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives.serialization import load_pem_public_key
@@ -19,132 +22,254 @@ logger = logging.getLogger(__name__)
TRIAL_GRACE_PERIOD = timedelta(7, 0) # 1 week
-MONTHLY_GRACE_PERIOD = timedelta(30, 0) # 1 month
+MONTHLY_GRACE_PERIOD = timedelta(335, 0) # 11 months
YEARLY_GRACE_PERIOD = timedelta(90, 0) # 3 months
-LICENSE_PRODUCT_NAME = "quay-enterprise"
LICENSE_FILENAME = 'license'
-class LicenseError(Exception):
+class LicenseDecodeError(Exception):
""" Exception raised if the license could not be read, decoded or has expired. """
pass
-class LicenseDecodeError(LicenseError):
- """ Exception raised if the license could not be decoded. """
- pass
-
-
-class LicenseValidationError(LicenseError):
- """ Exception raised if the license could not be validated. """
- pass
-
-
-def _get_date(decoded, field):
+def _get_date(decoded, field, default_date=datetime.min):
""" Retrieves the encoded date found at the given field under the decoded license block. """
date_str = decoded.get(field)
- return parser.parse(date_str).replace(tzinfo=None) if date_str else None
+ return parser.parse(date_str).replace(tzinfo=None) if date_str else default_date
-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)
+@total_ordering
+class Entitlement(object):
+ """ An entitlement is a specific piece of software or functionality granted
+ by a license. It has an expiration date, as well as the count of the
+ things being granted. Entitlements are orderable by their counts.
+ """
+ def __init__(self, entitlement_name, count, product_name, expiration):
+ self.name = entitlement_name
+ self.count = count
+ self.product_name = product_name
+ self.expiration = expiration
- def check_expired(self, cutoff_date=None):
- return self.expiration_and_grace <= (cutoff_date or datetime.now())
+ def __lt__(self, rhs):
+ return self.count < rhs.count
+
+ def __repr__(self):
+ return str(dict(
+ name=self.name,
+ count=self.count,
+ product_name=self.product_name,
+ expiration=repr(self.expiration),
+ ))
+
+ def as_dict(self, for_private=False):
+ data = {
+ 'name': self.name,
+ }
+
+ if for_private:
+ data.update({
+ 'count': self.count,
+ 'product_name': self.product_name,
+ 'expiration': self.expiration.as_dict(for_private=True),
+ })
+
+ return data
+
+class ExpirationType(Enum):
+ """ An enum which represents the different possible types of expirations. If
+ you posess an expired enum, you can use this to figure out at what level
+ the expiration was most restrictive.
+ """
+ license_wide = 'License Wide Expiration'
+ trial_only = 'Trial Only Expiration'
+ in_trial = 'In-Trial Expiration'
+ monthly = 'Monthly Subscription Expiration'
+ yearly = 'Yearly Subscription Expiration'
+
+
+@total_ordering
+class Expiration(object):
+ """ An Expiration is an orderable representation of an expiration date and a
+ grace period. If you sort Expiration objects, they will be sorted by the
+ actual cutoff date, which is the combination of the expiration date and
+ the grace period.
+ """
+ def __init__(self, expiration_type, exp_date, grace_period=timedelta(seconds=0)):
+ self.expiration_type = expiration_type
+ self.expiration_date = exp_date
+ self.grace_period = grace_period
@property
- def expiration_and_grace(self):
+ def expires_at(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())
+ def is_expired(self, now):
+ """ Check if the current object should already be considered expired when
+ compared with the passed in datetime object.
+ """
+ return self.expires_at < now
+
+ def __lt__(self, rhs):
+ return self.expires_at < rhs.expires_at
+
+ def __repr__(self):
+ return str(dict(
+ expiration_type=repr(self.expiration_type),
+ expiration_date=repr(self.expiration_date),
+ grace_period=repr(self.grace_period),
+ ))
+
+ def as_dict(self, for_private=False):
+ data = {
+ 'expiration_type': str(self.expiration_type),
+ }
+
+ if for_private:
+ data.update({
+ 'expiration_date': str(self.expiration_date),
+ 'grace_period': str(self.grace_period),
+ })
+
+ return data
+
+
+class EntitlementStatus(IntEnum):
+ """ An EntitlementStatus represent the current effectiveness of an
+ Entitlement when compared with its corresponding requirement. As an
+ example, if the software requires 9 items, and the Entitlement only
+ provides for 7, you would use an insufficient_count status.
+ """
+ met = 0
+ expired = 1
+ insufficient_count = 2
+ no_matching = 3
+
+
+@total_ordering
+class EntitlementValidationResult(object):
+ """ An EntitlementValidationResult encodes the combination of a specific
+ entitlement and the software requirement which caused it to be examined.
+ They are orderable by the value of the EntitlementStatus enum, and will
+ in general be sorted by most to least satisfiable status type.
+ """
+ def __init__(self, requirement, created_at, entitlement=None):
+ self.requirement = requirement
+ self._created_at = created_at
+ self.entitlement = entitlement
+
+ def get_status(self):
+ """ Returns the EntitlementStatus when comparing the specified Entitlement
+ with the corresponding requirement.
+ """
+ if self.entitlement is not None:
+ if self.entitlement.expiration.is_expired(self._created_at):
+ return EntitlementStatus.expired
+
+ if self.entitlement.count < self.requirement.count:
+ return EntitlementStatus.insufficient_count
+
+ return EntitlementStatus.met
+
+ return EntitlementStatus.no_matching
+
+ def is_met(self):
+ """ Returns whether this specific EntitlementValidationResult meets all
+ of the criteria for being sufficient, including unexpired (or in the
+ grace period), and with a sufficient count.
+ """
+ return self.get_status() == EntitlementStatus.met
+
+ def __lt__(self, rhs):
+ return self.get_status() < rhs.get_status()
+
+ def __repr__(self):
+ return str(dict(
+ requirement=repr(self.requirement),
+ created_at=repr(self._created_at),
+ entitlement=repr(self.entitlement),
+ ))
+
+ def as_dict(self, for_private=False):
+ def req_view():
+ return {
+ 'name': self.requirement.name,
+ 'count': self.requirement.count,
+ }
+
+ data = {
+ 'requirement': req_view(),
+ 'status': str(self.get_status()),
+ }
+
+ if self.entitlement is not None:
+ data['entitlement'] = self.entitlement.as_dict(for_private=for_private)
+
+ return data
+
class License(object):
""" License represents a fully decoded and validated (but potentially expired) license. """
def __init__(self, decoded):
self.decoded = decoded
- @property
- def subscription(self):
- """ Returns the Quay Enterprise subscription, if any. """
+ def validate_entitlement_requirement(self, entitlement_req, check_time):
+ all_active_entitlements = list(self._find_entitlements(entitlement_req.name))
+
+ if len(all_active_entitlements) == 0:
+ return EntitlementValidationResult(entitlement_req, check_time)
+
+ entitlement_results = [EntitlementValidationResult(entitlement_req, check_time, ent)
+ for ent in all_active_entitlements]
+ entitlement_results.sort()
+ return entitlement_results[0]
+
+ def _find_entitlements(self, entitlement_name):
+ license_expiration = Expiration(
+ ExpirationType.license_wide,
+ _get_date(self.decoded, 'expirationDate'),
+ )
+
for sub in self.decoded.get('subscriptions', {}).values():
- if sub.get('productName') == LICENSE_PRODUCT_NAME:
- return sub
+ entitlement_count = sub.get('entitlements', {}).get(entitlement_name)
- return None
+ if entitlement_count is not None:
+ entitlement_expiration = min(self._sub_expiration(sub), license_expiration)
+ yield Entitlement(
+ entitlement_name,
+ entitlement_count,
+ sub.get('productName', 'unknown'),
+ entitlement_expiration,
+ )
- @property
- def is_expired(self):
- cutoff_date = datetime.now()
- return bool([dt for dt in self._get_expiration_dates() if dt.check_expired(cutoff_date)])
+ @staticmethod
+ def _sub_expiration(subscription):
+ # A trial license has its own end logic, and uses the trialEnd property
+ if subscription.get('trialOnly', False):
+ trial_expiration = Expiration(
+ ExpirationType.trial_only,
+ _get_date(subscription, 'trialEnd'),
+ TRIAL_GRACE_PERIOD,
+ )
+ return trial_expiration
+
+ # From here we always use the serviceEnd
+ service_end = _get_date(subscription, 'serviceEnd')
+
+ if subscription.get('inTrial', False):
+ return Expiration(ExpirationType.in_trial, service_end, TRIAL_GRACE_PERIOD)
+
+ if subscription.get('durationPeriod') == 'yearly':
+ return Expiration(ExpirationType.yearly, service_end, YEARLY_GRACE_PERIOD)
+
+ # We assume monthly license unless specified otherwise
+ return Expiration(ExpirationType.monthly, service_end, MONTHLY_GRACE_PERIOD)
def validate(self, config):
- """ Validates the license and all its entitlements against the given config. """
- # Check that the license has not expired.
- if self.is_expired:
- raise LicenseValidationError('License has expired')
-
- # Check the maximum number of replication regions.
- max_regions = min(self.decoded.get('entitlements', {}).get('software.quay.regions', 1), 1)
- config_regions = len(config.get('DISTRIBUTED_STORAGE_CONFIG', []))
- if max_regions != -1 and config_regions > max_regions:
- msg = '{} regions configured, but license file allows up to {}'.format(config_regions,
- max_regions)
- raise LicenseValidationError(msg)
-
- def _get_expiration_dates(self):
- # Check if the license overall has expired.
- expiration_date = _get_date(self.decoded, 'expirationDate')
- 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:
- 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')
- 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)
- 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":
- yield LicenseExpirationDate('Monthly subscription', service_end_date, MONTHLY_GRACE_PERIOD)
-
- if duration_period == "years":
- yield LicenseExpirationDate('Yearly subscription', service_end_date, YEARLY_GRACE_PERIOD)
-
+ """ Returns a list of EntitlementValidationResult objects, one per requirement.
+ """
+ requirements = _gen_entitlement_requirements(config)
+ now = datetime.now()
+ return [self.validate_entitlement_requirement(req, now) for req in requirements]
_PROD_LICENSE_PUBLIC_KEY_DATA = """
@@ -183,6 +308,17 @@ LICENSE_VALIDATION_INTERVAL = 3600 # seconds
LICENSE_VALIDATION_EXPIRED_INTERVAL = 60 # seconds
+EntitlementRequirement = namedtuple('EntitlementRequirements', ['name', 'count'])
+
+
+def _gen_entitlement_requirements(config_obj):
+ config_regions = len(config_obj.get('DISTRIBUTED_STORAGE_CONFIG', []))
+ return [
+ EntitlementRequirement('software.quay', 1),
+ EntitlementRequirement('software.quay.regions', config_regions),
+ ]
+
+
class LicenseValidator(Thread):
"""
LicenseValidator is a thread that asynchronously reloads and validates license files.
@@ -191,36 +327,44 @@ class LicenseValidator(Thread):
synchronization primitive.
"""
def __init__(self, config_provider, *args, **kwargs):
+ config = config_provider.get_config() or {}
+
self._config_provider = config_provider
+ self._entitlement_requirements = _gen_entitlement_requirements(config)
# multiprocessing.Value does not ensure consistent write-after-reads, but we don't need that.
- self._license_is_expired = multiprocessing.Value(c_bool, True)
+ self._license_is_insufficient = multiprocessing.Value(c_bool, True)
super(LicenseValidator, self).__init__(*args, **kwargs)
self.daemon = True
@property
- def expired(self):
- return self._license_is_expired.value
+ def insufficient(self):
+ return self._license_is_insufficient.value
- def _check_expiration(self):
+ def compute_license_sufficiency(self):
+ """ Check whether all of our requirements are met, and set the status of
+ the result of the check, which will be used to disable the software.
+ Returns True if any requirements are not met, and False if all are met.
+ """
try:
current_license = self._config_provider.get_license()
- is_expired = current_license.is_expired
- logger.debug('updating license expiration to %s', is_expired)
- self._license_is_expired.value = is_expired
- except (IOError, LicenseError):
+ now = datetime.now()
+ any_invalid = not all(current_license.validate_entitlement_requirement(req, now).is_met()
+ for req in self._entitlement_requirements)
+ logger.debug('updating license license_is_insufficient to %s', any_invalid)
+ except (IOError, LicenseDecodeError):
logger.exception('failed to validate license')
- is_expired = True
- self._license_is_expired.value = is_expired
+ any_invalid = True
- return is_expired
+ self._license_is_insufficient.value = any_invalid
+ return any_invalid
def run(self):
logger.debug('Starting license validation thread')
while True:
- expired = self._check_expiration()
- sleep_time = LICENSE_VALIDATION_EXPIRED_INTERVAL if expired else LICENSE_VALIDATION_INTERVAL
+ invalid = self.compute_license_sufficiency()
+ sleep_time = LICENSE_VALIDATION_EXPIRED_INTERVAL if invalid else LICENSE_VALIDATION_INTERVAL
logger.debug('waiting %d seconds before retrying to validate license', sleep_time)
time.sleep(sleep_time)
@@ -231,11 +375,11 @@ class LicenseValidator(Thread):
"""
if response_func is None:
def _response_func():
- return make_response('License has expired.', 402)
+ return make_response('License is insufficient.', 402)
response_func = _response_func
def _enforce_license():
- if self.expired:
- logger.debug('blocked interaction due to expired license')
+ if self.insufficient:
+ logger.debug('blocked interaction due to insufficient license')
return response_func()
blueprint.before_request(_enforce_license)