Merge pull request #2009 from coreos-inc/qe2-license
Add license support for QE
This commit is contained in:
commit
2a7dbd3348
23 changed files with 902 additions and 219 deletions
41
app.py
41
app.py
|
@ -1,47 +1,51 @@
|
|||
import json
|
||||
import logging
|
||||
import os
|
||||
import json
|
||||
|
||||
from functools import partial
|
||||
|
||||
from Crypto.PublicKey import RSA
|
||||
from flask import Flask, request, Request, _request_ctx_stack
|
||||
from flask_principal import Principal
|
||||
from flask_login import LoginManager, UserMixin
|
||||
from flask_mail import Mail
|
||||
from werkzeug.routing import BaseConverter
|
||||
from flask_principal import Principal
|
||||
from jwkest.jwk import RSAKey
|
||||
from Crypto.PublicKey import RSA
|
||||
from werkzeug.routing import BaseConverter
|
||||
|
||||
import features
|
||||
|
||||
from avatars.avatars import Avatar
|
||||
from storage import Storage
|
||||
from data import model
|
||||
from data import database
|
||||
from data.userfiles import Userfiles
|
||||
from data.users import UserAuthentication
|
||||
from data import model
|
||||
from data.archivedlogs import LogArchive
|
||||
from data.billing import Billing
|
||||
from data.buildlogs import BuildLogs
|
||||
from data.archivedlogs import LogArchive
|
||||
from data.userevent import UserEventsBuilderModule
|
||||
from data.queue import WorkQueue, BuildMetricQueueReporter
|
||||
from data.userevent import UserEventsBuilderModule
|
||||
from data.userfiles import Userfiles
|
||||
from data.users import UserAuthentication
|
||||
from storage import Storage
|
||||
from util import get_app_url
|
||||
from util.saas.analytics import Analytics
|
||||
from util.saas.useranalytics import UserAnalytics
|
||||
from util.saas.exceptionlog import Sentry
|
||||
from util.names import urn_generator
|
||||
from util.config.configutil import generate_secret_key
|
||||
from util.config.oauth import (GoogleOAuthConfig, GithubOAuthConfig, GitLabOAuthConfig,
|
||||
DexOAuthConfig)
|
||||
|
||||
from util.security.signing import Signer
|
||||
from util.security.instancekeys import InstanceKeys
|
||||
from util.saas.cloudwatch import start_cloudwatch_sender
|
||||
from util.config.provider import get_config_provider
|
||||
from util.config.configutil import generate_secret_key
|
||||
from util.config.superusermanager import SuperUserManager
|
||||
from util.secscan.api import SecurityScannerAPI
|
||||
from util.label_validator import LabelValidator
|
||||
from util.license import LicenseValidator, LICENSE_FILENAME
|
||||
from util.metrics.metricqueue import MetricQueue
|
||||
from util.metrics.prometheus import PrometheusPlugin
|
||||
from util.label_validator import LabelValidator
|
||||
from util.names import urn_generator
|
||||
from util.saas.analytics import Analytics
|
||||
from util.saas.cloudwatch import start_cloudwatch_sender
|
||||
from util.saas.exceptionlog import Sentry
|
||||
from util.secscan.api import SecurityScannerAPI
|
||||
from util.security.instancekeys import InstanceKeys
|
||||
from util.security.signing import Signer
|
||||
|
||||
OVERRIDE_CONFIG_DIRECTORY = 'conf/stack/'
|
||||
OVERRIDE_CONFIG_YAML_FILENAME = 'conf/stack/config.yaml'
|
||||
|
@ -189,6 +193,9 @@ signer = Signer(app, config_provider)
|
|||
instance_keys = InstanceKeys(app)
|
||||
label_validator = LabelValidator(app)
|
||||
|
||||
license_validator = LicenseValidator(os.path.join(OVERRIDE_CONFIG_DIRECTORY, LICENSE_FILENAME))
|
||||
license_validator.start()
|
||||
|
||||
start_cloudwatch_sender(metric_queue, app)
|
||||
|
||||
tf = app.config['DB_TRANSACTION_FACTORY']
|
||||
|
|
|
@ -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.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)
|
||||
|
|
|
@ -18,11 +18,12 @@ from auth.permissions import SuperUserPermission
|
|||
from endpoints.api import (ApiResource, nickname, resource, validate_json_request,
|
||||
internal_only, require_scope, show_if, parse_args,
|
||||
query_param, abort, require_fresh_login, path_param, verify_not_prod,
|
||||
page_support, log_action)
|
||||
page_support, log_action, InvalidRequest)
|
||||
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
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
@ -819,3 +820,143 @@ class SuperUserServiceKeyApproval(ApiResource):
|
|||
return make_response('', 201)
|
||||
|
||||
abort(403)
|
||||
|
||||
|
||||
@resource('/v1/superuser/license')
|
||||
@internal_only
|
||||
@show_if(features.SUPER_USERS)
|
||||
class SuperUserLicense(ApiResource):
|
||||
""" Resource for getting and setting a license. """
|
||||
schemas = {
|
||||
'UpdateLicense': {
|
||||
'type': 'object',
|
||||
'description': 'Updates a license',
|
||||
'required': [
|
||||
'license',
|
||||
],
|
||||
'properties': {
|
||||
'license': {
|
||||
'type': 'string'
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@nickname('getLicense')
|
||||
@require_fresh_login
|
||||
@require_scope(scopes.SUPERUSER)
|
||||
@verify_not_prod
|
||||
def get(self):
|
||||
""" Returns the current decoded license. """
|
||||
if SuperUserPermission().can():
|
||||
try:
|
||||
decoded_license = config_provider.get_license()
|
||||
except LicenseError as le:
|
||||
raise InvalidRequest(le.message)
|
||||
|
||||
if decoded_license.is_expired:
|
||||
raise InvalidRequest('License has expired')
|
||||
|
||||
return {
|
||||
'decoded': decoded_license.subscription,
|
||||
'success': True
|
||||
}
|
||||
|
||||
abort(403)
|
||||
|
||||
@nickname('updateLicense')
|
||||
@require_fresh_login
|
||||
@require_scope(scopes.SUPERUSER)
|
||||
@verify_not_prod
|
||||
@validate_json_request('UpdateLicense')
|
||||
def put(self):
|
||||
""" Validates the given license contents and then saves it to the config volume. """
|
||||
if SuperUserPermission().can():
|
||||
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
|
||||
}
|
||||
|
||||
abort(403)
|
||||
|
||||
|
||||
@resource('/v1/messages')
|
||||
@show_if(features.SUPER_USERS)
|
||||
class SuperUserMessages(ApiResource):
|
||||
""" Resource for getting a list of super user messages """
|
||||
|
||||
schemas = {
|
||||
'GetMessage': {
|
||||
'id': 'GetMessage',
|
||||
'type': 'object',
|
||||
'description': 'Messages that a super user has saved in the past',
|
||||
'properties': {
|
||||
'message': {
|
||||
'type': 'array',
|
||||
'description': 'A list of messages',
|
||||
'itemType': {
|
||||
'type': 'object',
|
||||
'properties': {
|
||||
'id': {
|
||||
'type': 'integer',
|
||||
'description': 'The message id',
|
||||
},
|
||||
'content': {
|
||||
'type': 'string',
|
||||
'description': 'The actual message',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'CreateMessage': {
|
||||
'id': 'CreateMessage',
|
||||
'type': 'object',
|
||||
'description': 'Create a new message',
|
||||
'properties': {
|
||||
'message': {
|
||||
'type': 'object',
|
||||
'description': 'A single message',
|
||||
'properties': {
|
||||
'content': {
|
||||
'type': 'string',
|
||||
'description': 'The actual message',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@nickname('getMessages')
|
||||
def get(self):
|
||||
""" Return a super users messages """
|
||||
return {
|
||||
'messages': [message_view(m) for m in model.message.get_messages()],
|
||||
}
|
||||
|
||||
@verify_not_prod
|
||||
@nickname('createMessages')
|
||||
@validate_json_request('CreateMessage')
|
||||
@require_scope(scopes.SUPERUSER)
|
||||
def post(self):
|
||||
""" Create a message """
|
||||
if SuperUserPermission().can():
|
||||
model.message.create([request.get_json()['message']])
|
||||
return make_response('', 201)
|
||||
abort(403)
|
||||
|
||||
|
||||
def message_view(message):
|
||||
return {'id': message.id, 'content': message.content}
|
||||
|
|
|
@ -1,14 +1,15 @@
|
|||
from flask import Blueprint, make_response
|
||||
|
||||
from app import metric_queue
|
||||
from app import metric_queue, license_validator
|
||||
from endpoints.decorators import anon_protect, anon_allowed
|
||||
from util.metrics.metricqueue import time_blueprint
|
||||
|
||||
|
||||
v1_bp = Blueprint('v1', __name__)
|
||||
|
||||
license_validator.enforce_license_before_request(v1_bp)
|
||||
time_blueprint(v1_bp, metric_queue)
|
||||
|
||||
|
||||
# Note: This is *not* part of the Docker index spec. This is here for our own health check,
|
||||
# since we have nginx handle the _ping below.
|
||||
@v1_bp.route('/_internal_ping')
|
||||
|
|
|
@ -9,7 +9,7 @@ from semantic_version import Spec
|
|||
|
||||
import features
|
||||
|
||||
from app import app, metric_queue, get_app_url
|
||||
from app import app, metric_queue, get_app_url, license_validator
|
||||
from auth.auth_context import get_grant_context
|
||||
from auth.permissions import (ReadRepositoryPermission, ModifyRepositoryPermission,
|
||||
AdministerRepositoryPermission)
|
||||
|
@ -18,16 +18,32 @@ from data import model
|
|||
from endpoints.decorators import anon_protect, anon_allowed
|
||||
from endpoints.v2.errors import V2RegistryException, Unauthorized
|
||||
from util.http import abort
|
||||
from util.registry.dockerver import docker_version
|
||||
from util.metrics.metricqueue import time_blueprint
|
||||
from util.registry.dockerver import docker_version
|
||||
from util.pagination import encrypt_page_token, decrypt_page_token
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
v2_bp = Blueprint('v2', __name__)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
v2_bp = Blueprint('v2', __name__)
|
||||
license_validator.enforce_license_before_request(v2_bp)
|
||||
time_blueprint(v2_bp, metric_queue)
|
||||
|
||||
|
||||
@v2_bp.app_errorhandler(V2RegistryException)
|
||||
def handle_registry_v2_exception(error):
|
||||
response = jsonify({
|
||||
'errors': [error.as_dict()]
|
||||
})
|
||||
|
||||
response.status_code = error.http_status_code
|
||||
if response.status_code == 401:
|
||||
response.headers.extend(get_auth_headers(repository=error.repository, scopes=error.scopes))
|
||||
logger.debug('sending response: %s', response.get_data())
|
||||
return response
|
||||
|
||||
|
||||
_MAX_RESULTS_PER_PAGE = 50
|
||||
|
||||
|
||||
|
@ -72,19 +88,6 @@ def paginate(limit_kwarg_name='limit', offset_kwarg_name='offset',
|
|||
return wrapper
|
||||
|
||||
|
||||
@v2_bp.app_errorhandler(V2RegistryException)
|
||||
def handle_registry_v2_exception(error):
|
||||
response = jsonify({
|
||||
'errors': [error.as_dict()]
|
||||
})
|
||||
|
||||
response.status_code = error.http_status_code
|
||||
if response.status_code == 401:
|
||||
response.headers.extend(get_auth_headers(repository=error.repository, scopes=error.scopes))
|
||||
logger.debug('sending response: %s', response.get_data())
|
||||
return response
|
||||
|
||||
|
||||
def _require_repo_permission(permission_class, scopes=None, allow_public=False):
|
||||
def wrapper(func):
|
||||
@wraps(func)
|
||||
|
@ -117,7 +120,6 @@ def get_input_stream(flask_request):
|
|||
return flask_request.stream
|
||||
|
||||
|
||||
# TODO remove when v2 is deployed everywhere
|
||||
def route_show_if(value):
|
||||
def decorator(f):
|
||||
@wraps(f)
|
||||
|
@ -129,6 +131,7 @@ def route_show_if(value):
|
|||
return decorated_function
|
||||
return decorator
|
||||
|
||||
|
||||
@v2_bp.route('/')
|
||||
@route_show_if(features.ADVERTISE_V2)
|
||||
@process_registry_jwt_auth()
|
||||
|
|
|
@ -5,7 +5,7 @@ from flask import redirect, Blueprint, abort, send_file, make_response, request
|
|||
|
||||
import features
|
||||
|
||||
from app import app, signer, storage, metric_queue
|
||||
from app import app, signer, storage, metric_queue, license_validator
|
||||
from auth.auth_context import get_authenticated_user
|
||||
from auth.permissions import ReadRepositoryPermission
|
||||
from auth.process import process_auth
|
||||
|
@ -25,9 +25,11 @@ from util.registry.torrent import (make_torrent, per_user_torrent_filename, publ
|
|||
PieceHasher)
|
||||
|
||||
|
||||
verbs = Blueprint('verbs', __name__)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
verbs = Blueprint('verbs', __name__)
|
||||
license_validator.enforce_license_before_request(verbs)
|
||||
|
||||
|
||||
def _open_stream(formatter, namespace, repository, tag, derived_image_id, repo_image, handlers):
|
||||
"""
|
||||
|
|
|
@ -567,6 +567,33 @@ a:focus {
|
|||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.config-license-field-element textarea {
|
||||
padding: 10px;
|
||||
margin-bottom: 10px;
|
||||
height: 250px;
|
||||
}
|
||||
|
||||
.config-license-field-element .license-status {
|
||||
margin-bottom: 26px;
|
||||
}
|
||||
|
||||
.config-license-field-element table td:first-child {
|
||||
width: 150px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.config-license-field-element .fa {
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
.config-license-field-element .license-valid h4 {
|
||||
color: #2FC98E;
|
||||
}
|
||||
|
||||
.config-license-field-element .license-invalid h4 {
|
||||
color: #D64456;
|
||||
}
|
||||
|
||||
.co-checkbox {
|
||||
position: relative;
|
||||
}
|
||||
|
|
56
static/css/pages/setup.css
Normal file
56
static/css/pages/setup.css
Normal 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;
|
||||
}
|
40
static/directives/config/config-license-field.html
Normal file
40
static/directives/config/config-license-field.html
Normal file
|
@ -0,0 +1,40 @@
|
|||
<div class="config-license-field-element">
|
||||
<!-- Note: This hidden input will only have a value if there is a valid license, ensuring that the user cannot save
|
||||
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="license-valid license-status" ng-show="state == 'license-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>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="license-invalid license-status" ng-show="state == 'license-error'">
|
||||
<h4><i class="fa fa-times-circle"></i> Validation Failed</h4>
|
||||
<h5>{{ licenseError }}</h5>
|
||||
</div>
|
||||
|
||||
<button class="btn btn-default" ng-show="!showingEditor" ng-click="showEditor($event)"><i class="fa fa-pencil"></i> Update License</button>
|
||||
|
||||
<div class="license-editor" ng-show="showingEditor">
|
||||
<p>
|
||||
Your license 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>.
|
||||
</p>
|
||||
|
||||
<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>
|
||||
|
||||
<button class="btn btn-primary" ng-show="state != 'validating-license'"
|
||||
ng-click="validateAndUpdate($event)" ng-disabled="!licenseContents">Update License</button>
|
||||
|
||||
<div class="license-validating" ng-show="state == 'validating-license'">
|
||||
<span class="cor-loader-inline"></span> Validating License
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
|
@ -3,6 +3,16 @@
|
|||
<div ng-show="config && config['SUPER_USERS']">
|
||||
<form id="configform" name="configform">
|
||||
|
||||
<!-- License -->
|
||||
<div class="co-panel">
|
||||
<div class="co-panel-heading">
|
||||
<i class="fa fa-credit-card-alt"></i> License
|
||||
</div>
|
||||
<div class="co-panel-body">
|
||||
<div class="config-license-field"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Basic Configuration -->
|
||||
<div class="co-panel">
|
||||
<div class="co-panel-heading">
|
||||
|
|
|
@ -1246,5 +1246,73 @@ angular.module("core-config-setup", ['angularFileUpload'])
|
|||
}
|
||||
};
|
||||
return directiveDefinitionObject;
|
||||
})
|
||||
|
||||
.directive('configLicenseField', function () {
|
||||
var directiveDefinitionObject = {
|
||||
priority: 0,
|
||||
templateUrl: '/static/directives/config/config-license-field.html',
|
||||
replace: false,
|
||||
transclude: false,
|
||||
restrict: 'C',
|
||||
scope: {
|
||||
},
|
||||
controller: function($scope, $element, ApiService, UserService) {
|
||||
$scope.state = 'loading-license';
|
||||
$scope.showingEditor = false;
|
||||
$scope.requiredBox = '';
|
||||
|
||||
var loadLicense = function() {
|
||||
ApiService.getLicense().then(function(resp) {
|
||||
$scope.state = 'license-valid';
|
||||
$scope.showingEditor = false;
|
||||
$scope.licenseDecoded = resp['decoded'];
|
||||
$scope.requiredBox = 'filled';
|
||||
}, function(resp) {
|
||||
$scope.licenseError = ApiService.getErrorMessage(resp);
|
||||
$scope.state = 'license-error';
|
||||
$scope.showingEditor = true;
|
||||
$scope.requiredBox = '';
|
||||
});
|
||||
};
|
||||
|
||||
UserService.updateUserIn($scope, function(user) {
|
||||
if (!user.anonymous) {
|
||||
loadLicense();
|
||||
}
|
||||
});
|
||||
|
||||
$scope.showEditor = function($event) {
|
||||
$event.preventDefault();
|
||||
$event.stopPropagation();
|
||||
|
||||
$scope.showingEditor = true;
|
||||
};
|
||||
|
||||
$scope.validateAndUpdate = function($event) {
|
||||
$event.preventDefault();
|
||||
$event.stopPropagation();
|
||||
|
||||
$scope.state = 'validating-license';
|
||||
|
||||
var data = {
|
||||
'license': $scope.licenseContents
|
||||
};
|
||||
|
||||
ApiService.updateLicense(data).then(function(resp) {
|
||||
$scope.state = 'license-valid';
|
||||
$scope.showingEditor = false;
|
||||
$scope.licenseDecoded = resp['decoded'];
|
||||
$scope.requiredBox = 'filled';
|
||||
}, function(resp) {
|
||||
$scope.licenseError = ApiService.getErrorMessage(resp);
|
||||
$scope.state = 'license-error';
|
||||
$scope.showingEditor = true;
|
||||
$scope.requiredBox = '';
|
||||
});
|
||||
};
|
||||
}
|
||||
};
|
||||
return directiveDefinitionObject;
|
||||
});
|
||||
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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)">
|
||||
|
|
|
@ -51,8 +51,8 @@ from endpoints.api.superuser import (SuperUserLogs, SuperUserList, SuperUserMana
|
|||
SuperUserOrganizationManagement, SuperUserOrganizationList,
|
||||
SuperUserAggregateLogs, SuperUserServiceKeyManagement,
|
||||
SuperUserServiceKey, SuperUserServiceKeyApproval,
|
||||
SuperUserTakeOwnership,)
|
||||
from endpoints.api.globalmessages import (GlobalUserMessage, GlobalUserMessages,)
|
||||
SuperUserTakeOwnership, SuperUserMessages, SuperUserLicense)
|
||||
from endpoints.api.globalmessages import GlobalUserMessage, GlobalUserMessages
|
||||
from endpoints.api.secscan import RepositoryImageSecurity
|
||||
from endpoints.api.manifest import RepositoryManifestLabels, ManageRepositoryManifestLabel
|
||||
|
||||
|
@ -4158,6 +4158,37 @@ class TestSuperUserList(ApiTestCase):
|
|||
self._run_test('GET', 200, 'devtable', None)
|
||||
|
||||
|
||||
class TestSuperUserLicense(ApiTestCase):
|
||||
def setUp(self):
|
||||
ApiTestCase.setUp(self)
|
||||
self._set_url(SuperUserLicense)
|
||||
|
||||
def test_get_anonymous(self):
|
||||
self._run_test('GET', 401, None, None)
|
||||
|
||||
def test_get_freshuser(self):
|
||||
self._run_test('GET', 403, 'freshuser', None)
|
||||
|
||||
def test_get_reader(self):
|
||||
self._run_test('GET', 403, 'reader', None)
|
||||
|
||||
def test_get_devtable(self):
|
||||
self._run_test('GET', 400, 'devtable', None)
|
||||
|
||||
|
||||
def test_put_anonymous(self):
|
||||
self._run_test('PUT', 401, None, {})
|
||||
|
||||
def test_put_freshuser(self):
|
||||
self._run_test('PUT', 403, 'freshuser', {'license': ''})
|
||||
|
||||
def test_put_reader(self):
|
||||
self._run_test('PUT', 403, 'reader', {'license': ''})
|
||||
|
||||
def test_put_devtable(self):
|
||||
self._run_test('PUT', 400, 'devtable', {'license': ''})
|
||||
|
||||
|
||||
class TestSuperUserManagement(ApiTestCase):
|
||||
def setUp(self):
|
||||
ApiTestCase.setUp(self)
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -3,13 +3,13 @@ import unittest
|
|||
from datetime import datetime, timedelta
|
||||
|
||||
import jwt
|
||||
import json
|
||||
|
||||
from Crypto.PublicKey import RSA
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
from cryptography.hazmat.primitives.serialization import load_der_public_key
|
||||
|
||||
from util.config.provider.license import (decode_license, LICENSE_PRODUCT_NAME,
|
||||
LicenseValidationError)
|
||||
from util.license import decode_license, LICENSE_PRODUCT_NAME, LicenseValidationError
|
||||
|
||||
|
||||
class TestLicense(unittest.TestCase):
|
||||
|
@ -22,10 +22,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 +57,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 +87,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):
|
||||
|
@ -107,18 +111,19 @@ class TestLicense(unittest.TestCase):
|
|||
self.assertFalse(license.is_expired)
|
||||
|
||||
def test_validate_basic_license(self):
|
||||
decoded = self.get_license(timedelta(days=30), entitlements={})
|
||||
decoded = self.get_license(timedelta(days=30), service_end=timedelta(days=40),
|
||||
duration='months', entitlements={})
|
||||
decoded.validate({'DISTRIBUTED_STORAGE_CONFIG': [{}]})
|
||||
|
||||
def test_validate_storage_entitlement_valid(self):
|
||||
decoded = self.get_license(timedelta(days=30), entitlements={
|
||||
decoded = self.get_license(timedelta(days=30), service_end=timedelta(days=40), entitlements={
|
||||
'software.quay.regions': 2,
|
||||
})
|
||||
|
||||
decoded.validate({'DISTRIBUTED_STORAGE_CONFIG': [{}]})
|
||||
|
||||
def test_validate_storage_entitlement_invalid(self):
|
||||
decoded = self.get_license(timedelta(days=30), entitlements={
|
||||
decoded = self.get_license(timedelta(days=30), service_end=timedelta(days=40), entitlements={
|
||||
'software.quay.regions': 1,
|
||||
})
|
||||
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -1,18 +1,22 @@
|
|||
import yaml
|
||||
import logging
|
||||
import yaml
|
||||
|
||||
from util.license import LICENSE_FILENAME, LicenseError, decode_license
|
||||
|
||||
from util.config.provider.license import LICENSE_FILENAME, LicenseError, decode_license
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CannotWriteConfigException(Exception):
|
||||
""" Exception raised when the config cannot be written. """
|
||||
pass
|
||||
|
||||
|
||||
class SetupIncompleteException(Exception):
|
||||
""" Exception raised when attempting to verify config that has not yet been setup. """
|
||||
pass
|
||||
|
||||
|
||||
def import_yaml(config_obj, config_file):
|
||||
with open(config_file) as f:
|
||||
c = yaml.safe_load(f)
|
||||
|
@ -33,6 +37,7 @@ def import_yaml(config_obj, config_file):
|
|||
def get_yaml(config_obj):
|
||||
return yaml.safe_dump(config_obj, encoding='utf-8', allow_unicode=True)
|
||||
|
||||
|
||||
def export_yaml(config_obj, config_file):
|
||||
try:
|
||||
with open(config_file, 'w') as f:
|
||||
|
@ -76,7 +81,11 @@ class BaseProvider(object):
|
|||
raise NotImplementedError
|
||||
|
||||
def get_volume_file(self, filename, mode='r'):
|
||||
""" Returns a Python file referring to the given name under the config override volumne. """
|
||||
""" 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,24 +100,29 @@ class BaseProvider(object):
|
|||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def validate_license(self, config):
|
||||
""" Validates that the configuration matches the license file (if any). """
|
||||
if not config.get('SETUP_COMPLETE', False):
|
||||
raise SetupIncompleteException()
|
||||
|
||||
with self._get_license_file() as f:
|
||||
license_file_contents = f.read()
|
||||
|
||||
self.license = decode_license(license_file_contents)
|
||||
self.license.validate(config)
|
||||
|
||||
def _get_license_file(self):
|
||||
""" Returns the contents of the license file. """
|
||||
if not self.has_license_file():
|
||||
msg = 'Could not find license file. Please make sure it is in your config volume.'
|
||||
raise LicenseError(msg)
|
||||
|
||||
try:
|
||||
return self.get_volume_file(LICENSE_FILENAME)
|
||||
except IOError:
|
||||
msg = 'Could not open license file. Please make sure it is in your config volume.'
|
||||
raise LicenseError(msg)
|
||||
|
||||
def get_license(self):
|
||||
""" Returns the decoded license, if any. """
|
||||
with self._get_license_file() as f:
|
||||
license_file_contents = f.read()
|
||||
|
||||
return decode_license(license_file_contents)
|
||||
|
||||
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)
|
||||
|
|
|
@ -50,7 +50,14 @@ class FileConfigProvider(BaseProvider):
|
|||
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)
|
||||
return open(os.path.join(self.config_volume, filename), mode=mode)
|
||||
|
||||
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)
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -1,136 +0,0 @@
|
|||
import logging
|
||||
|
||||
from dateutil import parser
|
||||
from datetime import datetime, timedelta
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
from cryptography.hazmat.primitives.serialization import load_pem_public_key
|
||||
|
||||
import jwt
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
TRIAL_GRACE_PERIOD = timedelta(7, 0) # 1 week
|
||||
MONTHLY_GRACE_PERIOD = timedelta(30, 0) # 1 month
|
||||
YEARLY_GRACE_PERIOD = timedelta(90, 0) # 3 months
|
||||
|
||||
LICENSE_PRODUCT_NAME = "quay-enterprise"
|
||||
|
||||
class LicenseError(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):
|
||||
""" Retrieves the encoded date found at the given field under the decoded license block. """
|
||||
date_str = decoded.get(field)
|
||||
if date_str:
|
||||
return parser.parse(date_str).replace(tzinfo=None)
|
||||
|
||||
return datetime.now() - timedelta(days=2)
|
||||
|
||||
|
||||
class License(object):
|
||||
""" License represents a fully decoded and validated (but potentially expired) license. """
|
||||
def __init__(self, decoded):
|
||||
self.decoded = decoded
|
||||
|
||||
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)
|
||||
|
||||
@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')
|
||||
if expiration_date <= compare_date:
|
||||
logger.debug('License expired on %s', expiration_date)
|
||||
return True
|
||||
|
||||
# Check for any QE subscriptions.
|
||||
for sub in self.decoded.get('subscriptions', []):
|
||||
if sub.get('productName') != LICENSE_PRODUCT_NAME:
|
||||
continue
|
||||
|
||||
# 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)
|
||||
|
||||
# Otherwise, check the service expiration.
|
||||
duration_period = sub.get('durationPeriod', 'monthly')
|
||||
|
||||
# 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 duration_period == "years":
|
||||
logger.debug('Yearly license expires on %s', service_end_date)
|
||||
return service_end_date <= (compare_date - YEARLY_GRACE_PERIOD)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
LICENSE_FILENAME = 'license'
|
||||
|
||||
|
||||
_PROD_LICENSE_PUBLIC_KEY_DATA = """
|
||||
-----BEGIN PUBLIC KEY-----
|
||||
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAuCkRnkuqox3A0djgRnHR
|
||||
e3U3jHrcbd5iUqdbfO/8E2TMbiByIy3NzUyJrMIzrTjdxTVIZF/ueaHLEtgaofUA
|
||||
1X73OZlsaGyNVDFA2eGZRgyNrmfLFoxnN2KB+gEJ88nPkHZXY+4ncZBjVMKfHQEv
|
||||
busC7xpnF7Diy2GxZKDZRnvjL4ZNrocdoeE0GuroWwebtck5Ea7LqzRxCJ5T3UWt
|
||||
EozttOBQAqCmKxSDdtdw+CsK/uTfl6Yh9xCZUrCeh5taSOHOvU0ne/p3gM+AsjU4
|
||||
ScjObTKaSUOGen6aYFF5Bd6V/ucxHmcmJlycwNZOKGFpbhLU173/oBJ+okvDbJpN
|
||||
qwIDAQAB
|
||||
-----END PUBLIC KEY-----
|
||||
"""
|
||||
|
||||
|
||||
_PROD_LICENSE_PUBLIC_KEY = load_pem_public_key(_PROD_LICENSE_PUBLIC_KEY_DATA,
|
||||
backend=default_backend())
|
||||
|
||||
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)
|
||||
except jwt.exceptions.DecodeError as de:
|
||||
logger.exception('Could not decode license file')
|
||||
raise LicenseDecodeError('Could not decode license found: %s' % de.message)
|
||||
|
||||
return License(decoded)
|
|
@ -1,5 +1,5 @@
|
|||
import json
|
||||
from StringIO import StringIO
|
||||
import io
|
||||
|
||||
from util.config.provider.baseprovider import BaseProvider
|
||||
|
||||
|
@ -46,11 +46,14 @@ 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)
|
||||
|
||||
return StringIO(self.files[filename])
|
||||
return io.BytesIO(self.files[filename])
|
||||
|
||||
def requires_restart(self, app_config):
|
||||
return False
|
||||
|
|
242
util/license.py
Normal file
242
util/license.py
Normal file
|
@ -0,0 +1,242 @@
|
|||
import json
|
||||
import logging
|
||||
import multiprocessing
|
||||
import time
|
||||
|
||||
from ctypes import c_bool
|
||||
from datetime import datetime, timedelta
|
||||
from threading import Thread
|
||||
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
from cryptography.hazmat.primitives.serialization import load_pem_public_key
|
||||
from dateutil import parser
|
||||
from flask import make_response
|
||||
|
||||
import jwt
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
TRIAL_GRACE_PERIOD = timedelta(7, 0) # 1 week
|
||||
MONTHLY_GRACE_PERIOD = timedelta(30, 0) # 1 month
|
||||
YEARLY_GRACE_PERIOD = timedelta(90, 0) # 3 months
|
||||
LICENSE_PRODUCT_NAME = "quay-enterprise"
|
||||
LICENSE_FILENAME = 'license'
|
||||
|
||||
|
||||
class LicenseError(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):
|
||||
""" 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
|
||||
|
||||
|
||||
class LicenseExpirationDate(object):
|
||||
def __init__(self, title, expiration_date, grace_period=None):
|
||||
self.title = title
|
||||
self.expiration_date = expiration_date
|
||||
self.grace_period = grace_period or timedelta(seconds=0)
|
||||
|
||||
def check_expired(self, cutoff_date=None):
|
||||
return self.expiration_and_grace <= (cutoff_date or datetime.now())
|
||||
|
||||
@property
|
||||
def expiration_and_grace(self):
|
||||
return self.expiration_date + self.grace_period
|
||||
|
||||
def __str__(self):
|
||||
return 'License expiration "%s" date %s with grace %s: %s' % (self.title, self.expiration_date,
|
||||
self.grace_period,
|
||||
self.check_expired())
|
||||
|
||||
class License(object):
|
||||
""" License represents a fully decoded and validated (but potentially expired) license. """
|
||||
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):
|
||||
cutoff_date = datetime.now()
|
||||
return bool([dt for dt in self._get_expiration_dates() if dt.check_expired(cutoff_date)])
|
||||
|
||||
def validate(self, config):
|
||||
""" Validates the license and all its entitlements against the given config. """
|
||||
# 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)
|
||||
|
||||
|
||||
|
||||
_PROD_LICENSE_PUBLIC_KEY_DATA = """
|
||||
-----BEGIN PUBLIC KEY-----
|
||||
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAuCkRnkuqox3A0djgRnHR
|
||||
e3U3jHrcbd5iUqdbfO/8E2TMbiByIy3NzUyJrMIzrTjdxTVIZF/ueaHLEtgaofUA
|
||||
1X73OZlsaGyNVDFA2eGZRgyNrmfLFoxnN2KB+gEJ88nPkHZXY+4ncZBjVMKfHQEv
|
||||
busC7xpnF7Diy2GxZKDZRnvjL4ZNrocdoeE0GuroWwebtck5Ea7LqzRxCJ5T3UWt
|
||||
EozttOBQAqCmKxSDdtdw+CsK/uTfl6Yh9xCZUrCeh5taSOHOvU0ne/p3gM+AsjU4
|
||||
ScjObTKaSUOGen6aYFF5Bd6V/ucxHmcmJlycwNZOKGFpbhLU173/oBJ+okvDbJpN
|
||||
qwIDAQAB
|
||||
-----END PUBLIC KEY-----
|
||||
"""
|
||||
_PROD_LICENSE_PUBLIC_KEY = load_pem_public_key(_PROD_LICENSE_PUBLIC_KEY_DATA,
|
||||
backend=default_backend())
|
||||
|
||||
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:
|
||||
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)
|
||||
|
||||
|
||||
LICENSE_VALIDATION_INTERVAL = 3600 # seconds
|
||||
LICENSE_VALIDATION_EXPIRED_INTERVAL = 60 # seconds
|
||||
|
||||
|
||||
class LicenseValidator(Thread):
|
||||
"""
|
||||
LicenseValidator is a thread that asynchronously reloads and validates license files.
|
||||
|
||||
This thread is meant to be run before registry gunicorn workers fork and uses shared memory as a
|
||||
synchronization primitive.
|
||||
"""
|
||||
def __init__(self, license_path, *args, **kwargs):
|
||||
self._license_path = license_path
|
||||
|
||||
# multiprocessing.Value does not ensure consistent write-after-reads, but we don't need that.
|
||||
self._license_is_expired = 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 _check_expiration(self):
|
||||
try:
|
||||
with open(self._license_path) as f:
|
||||
current_license = decode_license(f.read())
|
||||
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):
|
||||
logger.exception('failed to validate license')
|
||||
is_expired = True
|
||||
self._license_is_expired.value = is_expired
|
||||
|
||||
return is_expired
|
||||
|
||||
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
|
||||
logger.debug('waiting %d seconds before retrying to validate license', sleep_time)
|
||||
time.sleep(sleep_time)
|
||||
|
||||
def enforce_license_before_request(self, blueprint, response_func=None):
|
||||
"""
|
||||
Adds a pre-check to a Flask blueprint such that if the provided license_validator determines the
|
||||
license has become invalid, the client will receive a HTTP 402 response.
|
||||
"""
|
||||
if response_func is None:
|
||||
def _response_func():
|
||||
return make_response('License has expired.', 402)
|
||||
response_func = _response_func
|
||||
|
||||
def _enforce_license():
|
||||
if self.expired:
|
||||
logger.debug('blocked interaction due to expired license')
|
||||
return response_func()
|
||||
blueprint.before_request(_enforce_license)
|
Reference in a new issue