Add license upload step to the setup flow

Fixes #853
This commit is contained in:
Joseph Schorr 2015-12-08 15:00:50 -05:00
parent 5211c407ff
commit 8fe29c5b89
12 changed files with 320 additions and 60 deletions

View file

@ -6,7 +6,8 @@ import signal
from flask import abort
from endpoints.api import (ApiResource, nickname, resource, internal_only, show_if,
require_fresh_login, request, validate_json_request, verify_not_prod)
require_fresh_login, request, validate_json_request, verify_not_prod,
InvalidRequest)
from endpoints.common import common_login
from app import app, config_provider, superusers, OVERRIDE_CONFIG_DIRECTORY
@ -18,6 +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.config.provider.license import decode_license, LicenseError
from data.runmigration import run_alembic_migration
from data.users import get_federated_service_name, get_users_handler
@ -62,6 +64,12 @@ class SuperUserRegistryStatus(ApiResource):
'status': 'missing-config-dir'
}
# If there is no license file, we need to ask the user to upload it.
if not config_provider.has_license_file():
return {
'status': 'upload-license'
}
# If there is no config file, we need to setup the database.
if not config_provider.config_exists():
return {
@ -244,6 +252,50 @@ class SuperUserConfig(ApiResource):
abort(403)
@resource('/v1/superuser/config/license')
@internal_only
@show_if(features.SUPER_USERS)
class SuperUserSetAndValidateLicense(ApiResource):
""" Resource for setting and validating a license. """
schemas = {
'ValidateLicense': {
'type': 'object',
'description': 'Validates and sets a license',
'required': [
'license',
],
'properties': {
'license': {
'type': 'string'
},
},
},
}
@nickname('suSetAndValidateLicense')
@verify_not_prod
@validate_json_request('ValidateLicense')
def post(self):
""" Validates the given license contents and then saves it to the config volume. """
if config_provider.has_license_file():
abort(403)
license_contents = request.get_json()['license']
try:
decoded_license = decode_license(license_contents)
except LicenseError as le:
raise InvalidRequest(le.message)
if decoded_license.is_expired:
raise InvalidRequest('License has expired')
config_provider.save_license(license_contents)
return {
'decoded': decoded_license.subscription,
'success': True
}
@resource('/v1/superuser/config/file/<filename>')
@internal_only
@show_if(features.SUPER_USERS)

View file

@ -0,0 +1,56 @@
.initial-setup-modal .upload-license textarea {
border: 1px solid #eee !important;
transition: all ease-in-out 200ms;
resize: none;
}
.initial-setup-modal .upload-license textarea {
padding: 10px;
margin-top: 20px;
margin-bottom: 10px;
}
.initial-setup-modal .upload-license .validate-message {
display: inline-block;
margin-left: 10px;
margin-top: 10px;
}
.initial-setup-modal .upload-license .license-invalid h5 {
font-size: 18px;
color: red;
}
.initial-setup-modal .upload-license .license-invalid h6 {
margin-bottom: 10px;
font-size: 16px;
}
.initial-setup-modal .upload-license .license-invalid .fa {
margin-right: 6px;
}
.initial-setup-modal .license-valid h5 {
color: #2FC98E;
font-size: 16px;
margin-bottom: 16px;
}
.initial-setup-modal .license-valid .fa {
margin-right: 6px;
}
.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

@ -37,6 +37,15 @@
// The config.yaml exists but it is invalid.
'INVALID_CONFIG': 'config-invalid',
// 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',
@ -95,7 +104,10 @@
$scope.currentConfig = null;
$scope.currentState = {
'hasDatabaseSSLCert': false
'hasDatabaseSSLCert': false,
'licenseContents': '',
'licenseError': null,
'licenseDecoded': null,
};
$scope.$watch('currentStep', function(currentStep) {
@ -121,6 +133,7 @@
case $scope.States.CREATE_SUPERUSER:
case $scope.States.DB_RESTARTING:
case $scope.States.CONFIG_DB:
case $scope.States.UPLOAD_LICENSE:
case $scope.States.VALID_CONFIG:
case $scope.States.READY:
$('#setupModal').modal({
@ -131,6 +144,27 @@
}
});
$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() {
@ -166,6 +200,7 @@
var States = $scope.States;
return [
isStepFamily(step, States.UPLOAD_LICENSE),
isStepFamily(step, States.CONFIG_DB),
isStepFamily(step, States.DB_SETUP),
isStep(step, States.DB_RESTARTING),
@ -191,6 +226,10 @@
return false;
};
$scope.beginSetup = function() {
$scope.currentStep = $scope.States.CONFIG_DB;
};
$scope.showInvalidConfigDialog = function() {
var message = "The <code>config.yaml</code> file found in <code>conf/stack</code> could not be parsed."
var title = "Invalid configuration file";

View file

@ -9,12 +9,13 @@
<div class="cor-tab-panel" style="padding: 20px;">
<div class="co-alert alert alert-info">
<span class="cor-step-bar" progress="stepProgress">
<span class="cor-step" title="Configure Database" text="1"></span>
<span class="cor-step" title="Upload License" text="1"></span>
<span class="cor-step" title="Configure Database" text="2"></span>
<span class="cor-step" title="Setup Database" icon="database"></span>
<span class="cor-step" title="Container Restart" icon="refresh"></span>
<span class="cor-step" title="Create Superuser" text="2"></span>
<span class="cor-step" title="Configure Registry" text="3"></span>
<span class="cor-step" title="Validate Configuration" text="4"></span>
<span class="cor-step" title="Create Superuser" text="3"></span>
<span class="cor-step" title="Configure Registry" text="4"></span>
<span class="cor-step" title="Validate Configuration" text="5"></span>
<span class="cor-step" title="Container Restart" icon="refresh"></span>
<span class="cor-step" title="Setup Complete" icon="check"></span>
</span>
@ -36,12 +37,13 @@
<!-- Header -->
<div class="modal-header">
<span class="cor-step-bar" progress="stepProgress">
<span class="cor-step" title="Configure Database" text="1"></span>
<span class="cor-step" title="Upload License" text="1"></span>
<span class="cor-step" title="Configure Database" text="2"></span>
<span class="cor-step" title="Setup Database" icon="database"></span>
<span class="cor-step" title="Container Restart" icon="refresh"></span>
<span class="cor-step" title="Create Superuser" text="2"></span>
<span class="cor-step" title="Configure Registry" text="3"></span>
<span class="cor-step" title="Validate Configuration" text="4"></span>
<span class="cor-step" title="Create Superuser" text="3"></span>
<span class="cor-step" title="Configure Registry" text="4"></span>
<span class="cor-step" title="Validate Configuration" text="5"></span>
<span class="cor-step" title="Container Restart" icon="refresh"></span>
<span class="cor-step" title="Setup Complete" icon="check"></span>
</span>
@ -128,6 +130,37 @@
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'">
<h4>
Quay Enterprise License
</h4>
<div>
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>
<!-- Content: DB_SETUP or DB_SETUP_ERROR -->
<div class="modal-body" style="padding: 20px;"
ng-show="isStep(currentStep, States.DB_SETUP, States.DB_SETUP_ERROR)">
@ -226,6 +259,29 @@
Database Validation Issue: {{ errors.DatabaseValidationError }}
</div>
<!-- Footer: UPLOAD_LICENSE or VALIDATING_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
</button>
</div>
<!-- Footer: CONFIG_DB or DB_ERROR -->
<div class="modal-footer"
ng-show="isStep(currentStep, States.CONFIG_DB, States.DB_ERROR)">

View file

@ -3740,13 +3740,20 @@ class TestSuperUserCreateInitialSuperUser(ApiTestCase):
class TestSuperUserConfig(ApiTestCase):
def test_get_status_update_config(self):
# With no config the status should be 'config-db'.
# With no config the status should be 'upload-license'.
json = self.getJsonResponse(SuperUserRegistryStatus)
self.assertEquals('config-db', json['status'])
self.assertEquals('upload-license', json['status'])
# And the config should 401.
self.getResponse(SuperUserConfig, expected_code=401)
# Add a fake license file.
config_provider.save_license('something')
# With no config but a license the status should be 'config-db'.
json = self.getJsonResponse(SuperUserRegistryStatus)
self.assertEquals('config-db', json['status'])
# Add some fake config.
fake_config = {
'AUTHENTICATION_TYPE': 'Database',

View file

@ -3,6 +3,7 @@ import unittest
from datetime import datetime, timedelta
import jwt
import json
from Crypto.PublicKey import RSA
from cryptography.hazmat.backends import default_backend
@ -22,10 +23,14 @@ class TestLicense(unittest.TestCase):
return (public_key, private_key)
def create_license(self, license_data):
jwt_data = {
'license': json.dumps(license_data),
}
(public_key, private_key) = self.keys()
# Encode the license with the JWT key.
encoded = jwt.encode(license_data, private_key, algorithm='RS256')
encoded = jwt.encode(jwt_data, private_key, algorithm='RS256')
# Decode it into a license object.
return decode_license(encoded, public_key_instance=public_key)
@ -53,7 +58,7 @@ class TestLicense(unittest.TestCase):
if 'duration' in kwargs:
sub['durationPeriod'] = kwargs['duration']
license_data['subscriptions'] = [sub]
license_data['subscriptions'] = {'somesub': sub}
decoded_license = self.create_license(license_data)
return decoded_license
@ -83,15 +88,15 @@ class TestLicense(unittest.TestCase):
self.assertTrue(license.is_expired)
def test_monthly_license_valid(self):
license = self.get_license(timedelta(days=30), service_end=timedelta(days=10), duration='monthly')
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='monthly')
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='monthly')
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):

View file

@ -21,7 +21,7 @@ class TestSuperUserRegistryStatus(ApiTestCase):
def test_registry_status(self):
with ConfigForTesting():
json = self.getJsonResponse(SuperUserRegistryStatus)
self.assertEquals('config-db', json['status'])
self.assertEquals('upload-license', json['status'])
class TestSuperUserConfigFile(ApiTestCase):

View file

@ -75,8 +75,12 @@ class BaseProvider(object):
""" Returns whether the file with the given name exists under the config override volume. """
raise NotImplementedError
def get_volume_file(self, filename, mode='r'):
""" Returns a Python file referring to the given name under the config override volumne. """
def get_volume_file(self, filename):
""" Returns a Python file referring to the given name under the config override volume. """
raise NotImplementedError
def write_volume_file(self, filename, contents):
""" Writes the given contents to the config override volumne, with the given filename. """
raise NotImplementedError
def save_volume_file(self, filename, flask_file):
@ -91,6 +95,14 @@ class BaseProvider(object):
"""
raise NotImplementedError
def _get_license_file(self):
""" Returns the contents of the license file. """
try:
return self.get_volume_file(LICENSE_FILENAME)
except IOError:
msg = 'Could not open license file. Please make sure it is in your config volume.'
raise LicenseError(msg)
def validate_license(self, config):
""" Validates that the configuration matches the license file (if any). """
if not config.get('SETUP_COMPLETE', False):
@ -102,11 +114,10 @@ class BaseProvider(object):
self.license = decode_license(license_file_contents)
self.license.validate(config)
def _get_license_file(self):
""" Returns the contents of the license file. """
try:
return self.get_volume_file(LICENSE_FILENAME)
except IOError:
msg = 'Could not open license file. Please make sure it is in your config volume.'
raise LicenseError(msg)
def save_license(self, license_file_contents):
""" Saves the given contents as the license file. """
self.write_volume_file(LICENSE_FILENAME, license_file_contents)
def has_license_file(self):
""" Returns true if a license file was found in the config directory. """
return self.volume_file_exists(LICENSE_FILENAME)

View file

@ -49,8 +49,15 @@ class FileConfigProvider(BaseProvider):
def volume_file_exists(self, filename):
return os.path.exists(os.path.join(self.config_volume, filename))
def get_volume_file(self, filename, mode='r'):
return open(os.path.join(self.config_volume, filename), mode)
def get_volume_file(self, filename):
return open(os.path.join(self.config_volume, filename))
def write_volume_file(self, filename, contents):
filepath = os.path.join(self.config_volume, filename)
with open(filepath, mode='w') as f:
f.write(contents)
return filepath
def save_volume_file(self, filename, flask_file):
filepath = os.path.join(self.config_volume, filename)

View file

@ -47,6 +47,14 @@ class KubernetesConfigProvider(FileConfigProvider):
self._update_secret_file(self.yaml_filename, get_yaml(config_obj))
super(KubernetesConfigProvider, self).save_config(config_obj)
def write_volume_file(self, filename, contents):
super(KubernetesConfigProvider, self).write_volume_file(filename, contents)
try:
self._update_secret_file(filename, contents)
except IOError as ioe:
raise CannotWriteConfigException(str(ioe))
def save_volume_file(self, filename, flask_file):
filepath = super(KubernetesConfigProvider, self).save_volume_file(filename, flask_file)

View file

@ -6,6 +6,7 @@ from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives.serialization import load_pem_public_key
import jwt
import json
logger = logging.getLogger(__name__)
@ -44,6 +45,19 @@ class License(object):
def __init__(self, decoded):
self.decoded = decoded
@property
def subscription(self):
""" Returns the Quay Enterprise subscription, if any. """
for sub in self.decoded.get('subscriptions', {}).values():
if sub.get('productName') == LICENSE_PRODUCT_NAME:
return sub
return None
@property
def is_expired(self):
return self._get_expired(datetime.now())
def validate(self, config):
""" Validates the license and all its entitlements against the given config. """
# Check that the license has not expired.
@ -58,10 +72,6 @@ class License(object):
max_regions)
raise LicenseValidationError(msg)
@property
def is_expired(self):
return self._get_expired(datetime.now())
def _get_expired(self, compare_date):
# Check if the license overall has expired.
expiration_date = _get_date(self.decoded, 'expirationDate')
@ -70,37 +80,37 @@ class License(object):
return True
# Check for any QE subscriptions.
for sub in self.decoded.get('subscriptions', []):
if sub.get('productName') != LICENSE_PRODUCT_NAME:
continue
sub = self.subscription
if sub is None:
return True
# Check for a trial-only license.
if sub.get('trialOnly', False):
trial_end_date = _get_date(sub, 'trialEnd')
logger.debug('Trial-only license expires on %s', trial_end_date)
return trial_end_date <= (compare_date - TRIAL_GRACE_PERIOD)
# Check for a trial-only license.
if sub.get('trialOnly', False):
trial_end_date = _get_date(sub, 'trialEnd')
logger.debug('Trial-only license expires on %s', trial_end_date)
return trial_end_date <= (compare_date - TRIAL_GRACE_PERIOD)
# Check for a normal license that is in trial.
service_end_date = _get_date(sub, 'serviceEnd')
if sub.get('inTrial', False):
# If the subscription is in a trial, but not a trial only
# subscription, give 7 days after trial end to update license
# to one which has been paid (they've put in a credit card and it
# might auto convert, so we could assume it will auto-renew)
logger.debug('In-trial license expires on %s', service_end_date)
return service_end_date <= (compare_date - TRIAL_GRACE_PERIOD)
# Check for a normal license that is in trial.
service_end_date = _get_date(sub, 'serviceEnd')
if sub.get('inTrial', False):
# If the subscription is in a trial, but not a trial only
# subscription, give 7 days after trial end to update license
# to one which has been paid (they've put in a credit card and it
# might auto convert, so we could assume it will auto-renew)
logger.debug('In-trial license expires on %s', service_end_date)
return service_end_date <= (compare_date - TRIAL_GRACE_PERIOD)
# Otherwise, check the service expiration.
duration_period = sub.get('durationPeriod', 'monthly')
# Otherwise, check the service expiration.
duration_period = sub.get('durationPeriod', 'months')
# If the subscription is monthly, give 3 months grace period
if duration_period == "monthly":
logger.debug('Monthly license expires on %s', service_end_date)
return service_end_date <= (compare_date - MONTHLY_GRACE_PERIOD)
# If the subscription is monthly, give 3 months grace period
if duration_period == "months":
logger.debug('Monthly license expires on %s', service_end_date)
return service_end_date <= (compare_date - MONTHLY_GRACE_PERIOD)
if duration_period == "years":
logger.debug('Yearly license expires on %s', service_end_date)
return service_end_date <= (compare_date - YEARLY_GRACE_PERIOD)
if duration_period == "years":
logger.debug('Yearly license expires on %s', service_end_date)
return service_end_date <= (compare_date - YEARLY_GRACE_PERIOD)
return True
@ -128,9 +138,15 @@ def decode_license(license_contents, public_key_instance=None):
""" Decodes the specified license contents, returning the decoded license. """
license_public_key = public_key_instance or _PROD_LICENSE_PUBLIC_KEY
try:
decoded = jwt.decode(license_contents, key=license_public_key)
jwt_data = jwt.decode(license_contents, key=license_public_key)
except jwt.exceptions.DecodeError as de:
logger.exception('Could not decode license file')
raise LicenseDecodeError('Could not decode license found: %s' % de.message)
try:
decoded = json.loads(jwt_data.get('license', '{}'))
except ValueError as ve:
logger.exception('Could not decode license file')
raise LicenseDecodeError('Could not decode license found: %s' % ve.message)
return License(decoded)

View file

@ -46,6 +46,9 @@ class TestConfigProvider(BaseProvider):
def save_volume_file(self, filename, flask_file):
self.files[filename] = ''
def write_volume_file(self, filename, contents):
self.files[filename] = contents
def get_volume_file(self, filename, mode='r'):
if filename in REAL_FILES:
return open(filename, mode=mode)