Merge pull request #2018 from coreos-inc/reallicense

Change license code to work with multiple subscriptions
This commit is contained in:
josephschorr 2016-10-19 18:07:02 -04:00 committed by GitHub
commit 0935dd4ee4
12 changed files with 833 additions and 350 deletions

View file

@ -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,
}

View file

@ -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)

View file

@ -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;
}

View file

@ -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;
}

View file

@ -3,19 +3,54 @@
config if the license is invalid (since this box will be empty and therefore "required") -->
<input type="text" name="licenseRequiredBox" ng-model="requiredBox" style="visibility: hidden; height: 1px; position: absolute;" required>
<div class="cor-loader-inline" ng-show="state == 'loading-license'"></div>
<div class="cor-loader-inline" ng-show="state == LicenseStates.validating"></div>
<div class="license-valid license-status" ng-show="state == 'license-valid'">
<div class="license-valid license-status" ng-show="state == LicenseStates.valid">
<h4><i class="fa fa-check-circle"></i>License Valid</h4>
<table class="co-table">
<tr><td>Product:</td><td>{{ licenseDecoded.publicProductName || licenseDecoded.productName }}</td></tr>
<tr><td>Plan:</td><td>{{ licenseDecoded.publicPlanName || licenseDecoded.planName }}</td></tr>
<thead>
<td>Requirement</td>
<td>Required Count</td>
<td>Subscription</td>
<td>Subscription Count</td>
<td>Expiration Date</td>
</thead>
<tr ng-repeat="status in licenseStatus">
<td>{{ requirementTitles[status.requirement.name] }}</td>
<td>{{ status.requirement.count }}</td>
<td>{{ status.entitlement.product_name }}</td>
<td>{{ status.entitlement.count }}</td>
<td><span am-time-ago="status.entitlement.expiration.expiration_date" data-title="{{ status.entitlement.expiration.expiration_date }}" bs-tooltip></span></td>
</tr>
</table>
</div>
<div class="license-invalid license-status" ng-show="state == 'license-error'">
<div class="license-invalid license-status" ng-show="state == LicenseStates.invalid">
<h4><i class="fa fa-times-circle"></i> Validation Failed</h4>
<h5>{{ licenseError }}</h5>
<h5 ng-if="licenseError">{{ licenseError }}</h5>
<h5 ng-if="!licenseError && licenseStatus">
<p>The following errors were found:</p>
<ul>
<li ng-repeat="status in licenseStatus" ng-if="status.status != 'EntitlementStatus.met'">
<div ng-switch on="status.status">
<!-- insufficient_count -->
<div ng-switch-when="EntitlementStatus.insufficient_count">
<strong>{{ requirementTitles[status.requirement.name] }}</strong>: <code class="required">{{ status.requirement.count }}</code> <span ng-if="status.requirement.count != 1">are</span><span ng-if="status.requirement.count == 1">is</span> required: License provides <code>{{ status.entitlement.count }}</code>
</div>
<!-- no_matching -->
<div ng-switch-when="EntitlementStatus.no_matching">
<strong>{{ requirementTitles[status.requirement.name] }}</strong>: License is missing requirement
</div>
<!-- expired -->
<div ng-switch-when="EntitlementStatus.expired">
<strong>{{ requirementTitles[status.requirement.name] }}</strong>: Requirement expired on <code>{{ status.entitlement.expiration.expiration_date }}</code>
</div>
</div>
</li>
</ul>
</h5>
</div>
<button class="btn btn-default" ng-show="!showingEditor" ng-click="showEditor($event)"><i class="fa fa-pencil"></i> Update License</button>
@ -28,12 +63,12 @@
<textarea id="enterLicenseBox" ng-model="licenseContents" class="form-control"
placeholder="Paste your raw license here, which should already be in base64 format: GtqMjMwNDgyM3Vq..."
ng-readonly="state == 'validating-license'"></textarea>
ng-readonly="state == LicenseStates.validating"></textarea>
<button class="btn btn-primary" ng-show="state != 'validating-license'"
<button class="btn btn-primary" ng-show="state != LicenseStates.validating"
ng-click="validateAndUpdate($event)" ng-disabled="!licenseContents">Update License</button>
<div class="license-validating" ng-show="state == 'validating-license'">
<div class="license-validating" ng-show="state == LicenseStates.validating">
<span class="cor-loader-inline"></span> Validating License
</div>
</div>

View file

@ -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);
}
};
}
};

View file

@ -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() {

View file

@ -130,21 +130,9 @@
The container must be restarted to apply the configuration changes.
</div>
<!-- Content: VALIDATED_LICENSE -->
<div class="modal-body license-valid" style="padding: 20px;"
ng-show="isStep(currentStep, States.VALIDATED_LICENSE)">
<h5><i class="fa fa-check"></i> License Validated</h5>
Your license has been validated and saved. Please press "Next" to continue setup of your Quay Enterprise installation.
<table class="co-table">
<tr><td>Product:</td><td>{{ currentState.licenseDecoded.publicProductName || currentState.licenseDecoded.productName }}</td></tr>
<tr><td>Plan:</td><td>{{ currentState.licenseDecoded.publicPlanName || currentState.licenseDecoded.planName }}</td></tr>
</table>
</div>
<!-- Content: UPLOAD_LICENSE or VALIDATING_LICENSE -->
<div class="modal-body upload-license" style="padding: 20px;"
ng-show="isStep(currentStep, States.UPLOAD_LICENSE, States.VALIDATING_LICENSE)"
ng-class="isStep(currentStep, States.VALIDATING_LICENSE) ? 'validating' : 'entering'">
<!-- Content: UPLOAD_LICENSE -->
<div class="modal-body upload-license entering" style="padding: 20px;"
ng-show="isStep(currentStep, States.UPLOAD_LICENSE)">
<h4>
Quay Enterprise License
</h4>
@ -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 <a href="https://account.tectonic.com" target="_blank">Tectonic Account</a>.
</div>
<textarea id="enterLicenseBox" ng-model="currentState.licenseContents" placeholder="Paste your raw license here, which should already be in base64 format: GtqMjMwNDgyM3Vq..."
ng-readonly="isStep(currentStep, States.VALIDATING_LICENSE)"></textarea>
<div class="license-invalid" ng-visible="isStep(currentStep, States.UPLOAD_LICENSE) && currentState.licenseError">
<h5><i class="fa fa-times-circle"></i> Validation Failed</h5>
<h6>{{ currentState.licenseError }}</h6>
Please try copying your license from the Tectonic Account again.
</div>
<div class="config-license-field" for-setup="true" is-valid="currentState.licenseValid"></div>
</div>
<!-- Content: DB_SETUP or DB_SETUP_ERROR -->
@ -259,26 +241,12 @@
Database Validation Issue: {{ errors.DatabaseValidationError }}
</div>
<!-- Footer: UPLOAD_LICENSE or VALIDATING_LICENSE -->
<!-- Footer: UPLOAD_LICENSE -->
<div class="modal-footer"
ng-show="isStep(currentStep, States.UPLOAD_LICENSE, States.VALIDATING_LICENSE)">
<div ng-show="isStep(currentStep, States.VALIDATING_LICENSE)">
<span class="cor-loader-inline"></span>
Validating License...
</div>
<button type="submit" class="btn btn-primary" ng-click="validateLicense()"
ng-disabled="!currentState.licenseContents"
ng-show="isStep(currentStep, States.UPLOAD_LICENSE)">
Validate License
</button>
</div>
<!-- Footer: VALIDATED_LICENSE -->
<div class="modal-footer"
ng-show="isStep(currentStep, States.VALIDATED_LICENSE)">
<button type="submit" class="btn btn-primary" ng-click="beginSetup()">
Next
ng-show="isStep(currentStep, States.UPLOAD_LICENSE)">
<button type="submit" class="btn btn-primary" ng-click="beginSetup()"
ng-disabled="!currentState.licenseValid">
Continue
</button>
</div>

View file

@ -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()

View file

@ -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. """

View file

@ -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

View file

@ -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)