Merge pull request #2018 from coreos-inc/reallicense
Change license code to work with multiple subscriptions
This commit is contained in:
commit
0935dd4ee4
12 changed files with 833 additions and 350 deletions
|
@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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. """
|
||||
|
|
|
@ -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
|
||||
|
|
372
util/license.py
372
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)
|
||||
|
|
Reference in a new issue