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 logging
|
||||||
import os
|
import os
|
||||||
import json
|
|
||||||
|
|
||||||
from functools import partial
|
from functools import partial
|
||||||
|
|
||||||
|
from Crypto.PublicKey import RSA
|
||||||
from flask import Flask, request, Request, _request_ctx_stack
|
from flask import Flask, request, Request, _request_ctx_stack
|
||||||
from flask_principal import Principal
|
|
||||||
from flask_login import LoginManager, UserMixin
|
from flask_login import LoginManager, UserMixin
|
||||||
from flask_mail import Mail
|
from flask_mail import Mail
|
||||||
from werkzeug.routing import BaseConverter
|
from flask_principal import Principal
|
||||||
from jwkest.jwk import RSAKey
|
from jwkest.jwk import RSAKey
|
||||||
from Crypto.PublicKey import RSA
|
from werkzeug.routing import BaseConverter
|
||||||
|
|
||||||
import features
|
import features
|
||||||
|
|
||||||
from avatars.avatars import Avatar
|
from avatars.avatars import Avatar
|
||||||
from storage import Storage
|
|
||||||
from data import model
|
|
||||||
from data import database
|
from data import database
|
||||||
from data.userfiles import Userfiles
|
from data import model
|
||||||
from data.users import UserAuthentication
|
from data.archivedlogs import LogArchive
|
||||||
from data.billing import Billing
|
from data.billing import Billing
|
||||||
from data.buildlogs import BuildLogs
|
from data.buildlogs import BuildLogs
|
||||||
from data.archivedlogs import LogArchive
|
|
||||||
from data.userevent import UserEventsBuilderModule
|
|
||||||
from data.queue import WorkQueue, BuildMetricQueueReporter
|
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 import get_app_url
|
||||||
from util.saas.analytics import Analytics
|
from util.saas.analytics import Analytics
|
||||||
from util.saas.useranalytics import UserAnalytics
|
from util.saas.useranalytics import UserAnalytics
|
||||||
from util.saas.exceptionlog import Sentry
|
from util.saas.exceptionlog import Sentry
|
||||||
from util.names import urn_generator
|
from util.names import urn_generator
|
||||||
|
from util.config.configutil import generate_secret_key
|
||||||
from util.config.oauth import (GoogleOAuthConfig, GithubOAuthConfig, GitLabOAuthConfig,
|
from util.config.oauth import (GoogleOAuthConfig, GithubOAuthConfig, GitLabOAuthConfig,
|
||||||
DexOAuthConfig)
|
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.provider import get_config_provider
|
||||||
from util.config.configutil import generate_secret_key
|
|
||||||
from util.config.superusermanager import SuperUserManager
|
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.metricqueue import MetricQueue
|
||||||
from util.metrics.prometheus import PrometheusPlugin
|
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_DIRECTORY = 'conf/stack/'
|
||||||
OVERRIDE_CONFIG_YAML_FILENAME = 'conf/stack/config.yaml'
|
OVERRIDE_CONFIG_YAML_FILENAME = 'conf/stack/config.yaml'
|
||||||
|
@ -189,6 +193,9 @@ signer = Signer(app, config_provider)
|
||||||
instance_keys = InstanceKeys(app)
|
instance_keys = InstanceKeys(app)
|
||||||
label_validator = LabelValidator(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)
|
start_cloudwatch_sender(metric_queue, app)
|
||||||
|
|
||||||
tf = app.config['DB_TRANSACTION_FACTORY']
|
tf = app.config['DB_TRANSACTION_FACTORY']
|
||||||
|
|
|
@ -6,7 +6,8 @@ import signal
|
||||||
|
|
||||||
from flask import abort
|
from flask import abort
|
||||||
from endpoints.api import (ApiResource, nickname, resource, internal_only, show_if,
|
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 endpoints.common import common_login
|
||||||
from app import app, config_provider, superusers, OVERRIDE_CONFIG_DIRECTORY
|
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.configutil import add_enterprise_config_defaults
|
||||||
from util.config.database import sync_database_with_config
|
from util.config.database import sync_database_with_config
|
||||||
from util.config.validator import validate_service_for_config, CONFIG_FILENAMES
|
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.runmigration import run_alembic_migration
|
||||||
from data.users import get_federated_service_name, get_users_handler
|
from data.users import get_federated_service_name, get_users_handler
|
||||||
|
|
||||||
|
@ -62,6 +64,12 @@ class SuperUserRegistryStatus(ApiResource):
|
||||||
'status': 'missing-config-dir'
|
'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 there is no config file, we need to setup the database.
|
||||||
if not config_provider.config_exists():
|
if not config_provider.config_exists():
|
||||||
return {
|
return {
|
||||||
|
@ -244,6 +252,50 @@ class SuperUserConfig(ApiResource):
|
||||||
abort(403)
|
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>')
|
@resource('/v1/superuser/config/file/<filename>')
|
||||||
@internal_only
|
@internal_only
|
||||||
@show_if(features.SUPER_USERS)
|
@show_if(features.SUPER_USERS)
|
||||||
|
|
|
@ -18,11 +18,12 @@ from auth.permissions import SuperUserPermission
|
||||||
from endpoints.api import (ApiResource, nickname, resource, validate_json_request,
|
from endpoints.api import (ApiResource, nickname, resource, validate_json_request,
|
||||||
internal_only, require_scope, show_if, parse_args,
|
internal_only, require_scope, show_if, parse_args,
|
||||||
query_param, abort, require_fresh_login, path_param, verify_not_prod,
|
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 endpoints.api.logs import get_logs, get_aggregate_logs
|
||||||
from data import model
|
from data import model
|
||||||
from data.database import ServiceKeyApprovalType
|
from data.database import ServiceKeyApprovalType
|
||||||
from util.useremails import send_confirmation_email, send_recovery_email
|
from util.useremails import send_confirmation_email, send_recovery_email
|
||||||
|
from util.license import decode_license, LicenseError
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
@ -819,3 +820,143 @@ class SuperUserServiceKeyApproval(ApiResource):
|
||||||
return make_response('', 201)
|
return make_response('', 201)
|
||||||
|
|
||||||
abort(403)
|
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 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 endpoints.decorators import anon_protect, anon_allowed
|
||||||
from util.metrics.metricqueue import time_blueprint
|
from util.metrics.metricqueue import time_blueprint
|
||||||
|
|
||||||
|
|
||||||
v1_bp = Blueprint('v1', __name__)
|
v1_bp = Blueprint('v1', __name__)
|
||||||
|
license_validator.enforce_license_before_request(v1_bp)
|
||||||
time_blueprint(v1_bp, metric_queue)
|
time_blueprint(v1_bp, metric_queue)
|
||||||
|
|
||||||
|
|
||||||
# Note: This is *not* part of the Docker index spec. This is here for our own health check,
|
# 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.
|
# since we have nginx handle the _ping below.
|
||||||
@v1_bp.route('/_internal_ping')
|
@v1_bp.route('/_internal_ping')
|
||||||
|
|
|
@ -9,7 +9,7 @@ from semantic_version import Spec
|
||||||
|
|
||||||
import features
|
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.auth_context import get_grant_context
|
||||||
from auth.permissions import (ReadRepositoryPermission, ModifyRepositoryPermission,
|
from auth.permissions import (ReadRepositoryPermission, ModifyRepositoryPermission,
|
||||||
AdministerRepositoryPermission)
|
AdministerRepositoryPermission)
|
||||||
|
@ -18,16 +18,32 @@ from data import model
|
||||||
from endpoints.decorators import anon_protect, anon_allowed
|
from endpoints.decorators import anon_protect, anon_allowed
|
||||||
from endpoints.v2.errors import V2RegistryException, Unauthorized
|
from endpoints.v2.errors import V2RegistryException, Unauthorized
|
||||||
from util.http import abort
|
from util.http import abort
|
||||||
from util.registry.dockerver import docker_version
|
|
||||||
from util.metrics.metricqueue import time_blueprint
|
from util.metrics.metricqueue import time_blueprint
|
||||||
|
from util.registry.dockerver import docker_version
|
||||||
from util.pagination import encrypt_page_token, decrypt_page_token
|
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)
|
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
|
_MAX_RESULTS_PER_PAGE = 50
|
||||||
|
|
||||||
|
|
||||||
|
@ -72,19 +88,6 @@ def paginate(limit_kwarg_name='limit', offset_kwarg_name='offset',
|
||||||
return wrapper
|
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 _require_repo_permission(permission_class, scopes=None, allow_public=False):
|
||||||
def wrapper(func):
|
def wrapper(func):
|
||||||
@wraps(func)
|
@wraps(func)
|
||||||
|
@ -117,7 +120,6 @@ def get_input_stream(flask_request):
|
||||||
return flask_request.stream
|
return flask_request.stream
|
||||||
|
|
||||||
|
|
||||||
# TODO remove when v2 is deployed everywhere
|
|
||||||
def route_show_if(value):
|
def route_show_if(value):
|
||||||
def decorator(f):
|
def decorator(f):
|
||||||
@wraps(f)
|
@wraps(f)
|
||||||
|
@ -129,6 +131,7 @@ def route_show_if(value):
|
||||||
return decorated_function
|
return decorated_function
|
||||||
return decorator
|
return decorator
|
||||||
|
|
||||||
|
|
||||||
@v2_bp.route('/')
|
@v2_bp.route('/')
|
||||||
@route_show_if(features.ADVERTISE_V2)
|
@route_show_if(features.ADVERTISE_V2)
|
||||||
@process_registry_jwt_auth()
|
@process_registry_jwt_auth()
|
||||||
|
|
|
@ -5,7 +5,7 @@ from flask import redirect, Blueprint, abort, send_file, make_response, request
|
||||||
|
|
||||||
import features
|
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.auth_context import get_authenticated_user
|
||||||
from auth.permissions import ReadRepositoryPermission
|
from auth.permissions import ReadRepositoryPermission
|
||||||
from auth.process import process_auth
|
from auth.process import process_auth
|
||||||
|
@ -25,9 +25,11 @@ from util.registry.torrent import (make_torrent, per_user_torrent_filename, publ
|
||||||
PieceHasher)
|
PieceHasher)
|
||||||
|
|
||||||
|
|
||||||
verbs = Blueprint('verbs', __name__)
|
|
||||||
logger = logging.getLogger(__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):
|
def _open_stream(formatter, namespace, repository, tag, derived_image_id, repo_image, handlers):
|
||||||
"""
|
"""
|
||||||
|
|
|
@ -567,6 +567,33 @@ a:focus {
|
||||||
margin-right: 4px;
|
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 {
|
.co-checkbox {
|
||||||
position: relative;
|
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']">
|
<div ng-show="config && config['SUPER_USERS']">
|
||||||
<form id="configform" name="configform">
|
<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 -->
|
<!-- Basic Configuration -->
|
||||||
<div class="co-panel">
|
<div class="co-panel">
|
||||||
<div class="co-panel-heading">
|
<div class="co-panel-heading">
|
||||||
|
|
|
@ -1246,5 +1246,73 @@ angular.module("core-config-setup", ['angularFileUpload'])
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
return directiveDefinitionObject;
|
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.
|
// The config.yaml exists but it is invalid.
|
||||||
'INVALID_CONFIG': 'config-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.
|
// DB is being configured.
|
||||||
'CONFIG_DB': 'config-db',
|
'CONFIG_DB': 'config-db',
|
||||||
|
|
||||||
|
@ -95,7 +104,10 @@
|
||||||
$scope.currentConfig = null;
|
$scope.currentConfig = null;
|
||||||
|
|
||||||
$scope.currentState = {
|
$scope.currentState = {
|
||||||
'hasDatabaseSSLCert': false
|
'hasDatabaseSSLCert': false,
|
||||||
|
'licenseContents': '',
|
||||||
|
'licenseError': null,
|
||||||
|
'licenseDecoded': null
|
||||||
};
|
};
|
||||||
|
|
||||||
$scope.$watch('currentStep', function(currentStep) {
|
$scope.$watch('currentStep', function(currentStep) {
|
||||||
|
@ -121,6 +133,7 @@
|
||||||
case $scope.States.CREATE_SUPERUSER:
|
case $scope.States.CREATE_SUPERUSER:
|
||||||
case $scope.States.DB_RESTARTING:
|
case $scope.States.DB_RESTARTING:
|
||||||
case $scope.States.CONFIG_DB:
|
case $scope.States.CONFIG_DB:
|
||||||
|
case $scope.States.UPLOAD_LICENSE:
|
||||||
case $scope.States.VALID_CONFIG:
|
case $scope.States.VALID_CONFIG:
|
||||||
case $scope.States.READY:
|
case $scope.States.READY:
|
||||||
$('#setupModal').modal({
|
$('#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.restartContainer = function(state) {
|
||||||
$scope.currentStep = state;
|
$scope.currentStep = state;
|
||||||
ContainerService.restartContainer(function() {
|
ContainerService.restartContainer(function() {
|
||||||
|
@ -166,6 +200,7 @@
|
||||||
var States = $scope.States;
|
var States = $scope.States;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
isStepFamily(step, States.UPLOAD_LICENSE),
|
||||||
isStepFamily(step, States.CONFIG_DB),
|
isStepFamily(step, States.CONFIG_DB),
|
||||||
isStepFamily(step, States.DB_SETUP),
|
isStepFamily(step, States.DB_SETUP),
|
||||||
isStep(step, States.DB_RESTARTING),
|
isStep(step, States.DB_RESTARTING),
|
||||||
|
@ -191,6 +226,10 @@
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
$scope.beginSetup = function() {
|
||||||
|
$scope.currentStep = $scope.States.CONFIG_DB;
|
||||||
|
};
|
||||||
|
|
||||||
$scope.showInvalidConfigDialog = function() {
|
$scope.showInvalidConfigDialog = function() {
|
||||||
var message = "The <code>config.yaml</code> file found in <code>conf/stack</code> could not be parsed."
|
var message = "The <code>config.yaml</code> file found in <code>conf/stack</code> could not be parsed."
|
||||||
var title = "Invalid configuration file";
|
var title = "Invalid configuration file";
|
||||||
|
|
|
@ -9,12 +9,13 @@
|
||||||
<div class="cor-tab-panel" style="padding: 20px;">
|
<div class="cor-tab-panel" style="padding: 20px;">
|
||||||
<div class="co-alert alert alert-info">
|
<div class="co-alert alert alert-info">
|
||||||
<span class="cor-step-bar" progress="stepProgress">
|
<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="Setup Database" icon="database"></span>
|
||||||
<span class="cor-step" title="Container Restart" icon="refresh"></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="Create Superuser" text="3"></span>
|
||||||
<span class="cor-step" title="Configure Registry" text="3"></span>
|
<span class="cor-step" title="Configure Registry" text="4"></span>
|
||||||
<span class="cor-step" title="Validate Configuration" 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="Container Restart" icon="refresh"></span>
|
||||||
<span class="cor-step" title="Setup Complete" icon="check"></span>
|
<span class="cor-step" title="Setup Complete" icon="check"></span>
|
||||||
</span>
|
</span>
|
||||||
|
@ -36,12 +37,13 @@
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<span class="cor-step-bar" progress="stepProgress">
|
<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="Setup Database" icon="database"></span>
|
||||||
<span class="cor-step" title="Container Restart" icon="refresh"></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="Create Superuser" text="3"></span>
|
||||||
<span class="cor-step" title="Configure Registry" text="3"></span>
|
<span class="cor-step" title="Configure Registry" text="4"></span>
|
||||||
<span class="cor-step" title="Validate Configuration" 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="Container Restart" icon="refresh"></span>
|
||||||
<span class="cor-step" title="Setup Complete" icon="check"></span>
|
<span class="cor-step" title="Setup Complete" icon="check"></span>
|
||||||
</span>
|
</span>
|
||||||
|
@ -128,6 +130,37 @@
|
||||||
The container must be restarted to apply the configuration changes.
|
The container must be restarted to apply the configuration changes.
|
||||||
</div>
|
</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 -->
|
<!-- Content: DB_SETUP or DB_SETUP_ERROR -->
|
||||||
<div class="modal-body" style="padding: 20px;"
|
<div class="modal-body" style="padding: 20px;"
|
||||||
ng-show="isStep(currentStep, States.DB_SETUP, States.DB_SETUP_ERROR)">
|
ng-show="isStep(currentStep, States.DB_SETUP, States.DB_SETUP_ERROR)">
|
||||||
|
@ -226,6 +259,29 @@
|
||||||
Database Validation Issue: {{ errors.DatabaseValidationError }}
|
Database Validation Issue: {{ errors.DatabaseValidationError }}
|
||||||
</div>
|
</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 -->
|
<!-- Footer: CONFIG_DB or DB_ERROR -->
|
||||||
<div class="modal-footer"
|
<div class="modal-footer"
|
||||||
ng-show="isStep(currentStep, States.CONFIG_DB, States.DB_ERROR)">
|
ng-show="isStep(currentStep, States.CONFIG_DB, States.DB_ERROR)">
|
||||||
|
|
|
@ -51,8 +51,8 @@ from endpoints.api.superuser import (SuperUserLogs, SuperUserList, SuperUserMana
|
||||||
SuperUserOrganizationManagement, SuperUserOrganizationList,
|
SuperUserOrganizationManagement, SuperUserOrganizationList,
|
||||||
SuperUserAggregateLogs, SuperUserServiceKeyManagement,
|
SuperUserAggregateLogs, SuperUserServiceKeyManagement,
|
||||||
SuperUserServiceKey, SuperUserServiceKeyApproval,
|
SuperUserServiceKey, SuperUserServiceKeyApproval,
|
||||||
SuperUserTakeOwnership,)
|
SuperUserTakeOwnership, SuperUserMessages, SuperUserLicense)
|
||||||
from endpoints.api.globalmessages import (GlobalUserMessage, GlobalUserMessages,)
|
from endpoints.api.globalmessages import GlobalUserMessage, GlobalUserMessages
|
||||||
from endpoints.api.secscan import RepositoryImageSecurity
|
from endpoints.api.secscan import RepositoryImageSecurity
|
||||||
from endpoints.api.manifest import RepositoryManifestLabels, ManageRepositoryManifestLabel
|
from endpoints.api.manifest import RepositoryManifestLabels, ManageRepositoryManifestLabel
|
||||||
|
|
||||||
|
@ -4158,6 +4158,37 @@ class TestSuperUserList(ApiTestCase):
|
||||||
self._run_test('GET', 200, 'devtable', None)
|
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):
|
class TestSuperUserManagement(ApiTestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
ApiTestCase.setUp(self)
|
ApiTestCase.setUp(self)
|
||||||
|
|
|
@ -3740,13 +3740,20 @@ class TestSuperUserCreateInitialSuperUser(ApiTestCase):
|
||||||
|
|
||||||
class TestSuperUserConfig(ApiTestCase):
|
class TestSuperUserConfig(ApiTestCase):
|
||||||
def test_get_status_update_config(self):
|
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)
|
json = self.getJsonResponse(SuperUserRegistryStatus)
|
||||||
self.assertEquals('config-db', json['status'])
|
self.assertEquals('upload-license', json['status'])
|
||||||
|
|
||||||
# And the config should 401.
|
# And the config should 401.
|
||||||
self.getResponse(SuperUserConfig, expected_code=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.
|
# Add some fake config.
|
||||||
fake_config = {
|
fake_config = {
|
||||||
'AUTHENTICATION_TYPE': 'Database',
|
'AUTHENTICATION_TYPE': 'Database',
|
||||||
|
|
|
@ -3,13 +3,13 @@ import unittest
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
import jwt
|
import jwt
|
||||||
|
import json
|
||||||
|
|
||||||
from Crypto.PublicKey import RSA
|
from Crypto.PublicKey import RSA
|
||||||
from cryptography.hazmat.backends import default_backend
|
from cryptography.hazmat.backends import default_backend
|
||||||
from cryptography.hazmat.primitives.serialization import load_der_public_key
|
from cryptography.hazmat.primitives.serialization import load_der_public_key
|
||||||
|
|
||||||
from util.config.provider.license import (decode_license, LICENSE_PRODUCT_NAME,
|
from util.license import decode_license, LICENSE_PRODUCT_NAME, LicenseValidationError
|
||||||
LicenseValidationError)
|
|
||||||
|
|
||||||
|
|
||||||
class TestLicense(unittest.TestCase):
|
class TestLicense(unittest.TestCase):
|
||||||
|
@ -22,10 +22,14 @@ class TestLicense(unittest.TestCase):
|
||||||
return (public_key, private_key)
|
return (public_key, private_key)
|
||||||
|
|
||||||
def create_license(self, license_data):
|
def create_license(self, license_data):
|
||||||
|
jwt_data = {
|
||||||
|
'license': json.dumps(license_data),
|
||||||
|
}
|
||||||
|
|
||||||
(public_key, private_key) = self.keys()
|
(public_key, private_key) = self.keys()
|
||||||
|
|
||||||
# Encode the license with the JWT key.
|
# 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.
|
# Decode it into a license object.
|
||||||
return decode_license(encoded, public_key_instance=public_key)
|
return decode_license(encoded, public_key_instance=public_key)
|
||||||
|
@ -53,7 +57,7 @@ class TestLicense(unittest.TestCase):
|
||||||
if 'duration' in kwargs:
|
if 'duration' in kwargs:
|
||||||
sub['durationPeriod'] = kwargs['duration']
|
sub['durationPeriod'] = kwargs['duration']
|
||||||
|
|
||||||
license_data['subscriptions'] = [sub]
|
license_data['subscriptions'] = {'somesub': sub}
|
||||||
|
|
||||||
decoded_license = self.create_license(license_data)
|
decoded_license = self.create_license(license_data)
|
||||||
return decoded_license
|
return decoded_license
|
||||||
|
@ -83,15 +87,15 @@ class TestLicense(unittest.TestCase):
|
||||||
self.assertTrue(license.is_expired)
|
self.assertTrue(license.is_expired)
|
||||||
|
|
||||||
def test_monthly_license_valid(self):
|
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)
|
self.assertFalse(license.is_expired)
|
||||||
|
|
||||||
def test_monthly_license_withingrace(self):
|
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)
|
self.assertFalse(license.is_expired)
|
||||||
|
|
||||||
def test_monthly_license_outsidegrace(self):
|
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)
|
self.assertTrue(license.is_expired)
|
||||||
|
|
||||||
def test_yearly_license_withingrace(self):
|
def test_yearly_license_withingrace(self):
|
||||||
|
@ -107,18 +111,19 @@ class TestLicense(unittest.TestCase):
|
||||||
self.assertFalse(license.is_expired)
|
self.assertFalse(license.is_expired)
|
||||||
|
|
||||||
def test_validate_basic_license(self):
|
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': [{}]})
|
decoded.validate({'DISTRIBUTED_STORAGE_CONFIG': [{}]})
|
||||||
|
|
||||||
def test_validate_storage_entitlement_valid(self):
|
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,
|
'software.quay.regions': 2,
|
||||||
})
|
})
|
||||||
|
|
||||||
decoded.validate({'DISTRIBUTED_STORAGE_CONFIG': [{}]})
|
decoded.validate({'DISTRIBUTED_STORAGE_CONFIG': [{}]})
|
||||||
|
|
||||||
def test_validate_storage_entitlement_invalid(self):
|
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,
|
'software.quay.regions': 1,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
@ -21,7 +21,7 @@ class TestSuperUserRegistryStatus(ApiTestCase):
|
||||||
def test_registry_status(self):
|
def test_registry_status(self):
|
||||||
with ConfigForTesting():
|
with ConfigForTesting():
|
||||||
json = self.getJsonResponse(SuperUserRegistryStatus)
|
json = self.getJsonResponse(SuperUserRegistryStatus)
|
||||||
self.assertEquals('config-db', json['status'])
|
self.assertEquals('upload-license', json['status'])
|
||||||
|
|
||||||
|
|
||||||
class TestSuperUserConfigFile(ApiTestCase):
|
class TestSuperUserConfigFile(ApiTestCase):
|
||||||
|
|
|
@ -1,18 +1,22 @@
|
||||||
import yaml
|
|
||||||
import logging
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class CannotWriteConfigException(Exception):
|
class CannotWriteConfigException(Exception):
|
||||||
""" Exception raised when the config cannot be written. """
|
""" Exception raised when the config cannot be written. """
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class SetupIncompleteException(Exception):
|
class SetupIncompleteException(Exception):
|
||||||
""" Exception raised when attempting to verify config that has not yet been setup. """
|
""" Exception raised when attempting to verify config that has not yet been setup. """
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
def import_yaml(config_obj, config_file):
|
def import_yaml(config_obj, config_file):
|
||||||
with open(config_file) as f:
|
with open(config_file) as f:
|
||||||
c = yaml.safe_load(f)
|
c = yaml.safe_load(f)
|
||||||
|
@ -33,6 +37,7 @@ def import_yaml(config_obj, config_file):
|
||||||
def get_yaml(config_obj):
|
def get_yaml(config_obj):
|
||||||
return yaml.safe_dump(config_obj, encoding='utf-8', allow_unicode=True)
|
return yaml.safe_dump(config_obj, encoding='utf-8', allow_unicode=True)
|
||||||
|
|
||||||
|
|
||||||
def export_yaml(config_obj, config_file):
|
def export_yaml(config_obj, config_file):
|
||||||
try:
|
try:
|
||||||
with open(config_file, 'w') as f:
|
with open(config_file, 'w') as f:
|
||||||
|
@ -76,7 +81,11 @@ class BaseProvider(object):
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
def get_volume_file(self, filename, mode='r'):
|
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
|
raise NotImplementedError
|
||||||
|
|
||||||
def save_volume_file(self, filename, flask_file):
|
def save_volume_file(self, filename, flask_file):
|
||||||
|
@ -91,24 +100,29 @@ class BaseProvider(object):
|
||||||
"""
|
"""
|
||||||
raise NotImplementedError
|
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):
|
def _get_license_file(self):
|
||||||
""" Returns the contents of the license file. """
|
""" 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:
|
try:
|
||||||
return self.get_volume_file(LICENSE_FILENAME)
|
return self.get_volume_file(LICENSE_FILENAME)
|
||||||
except IOError:
|
except IOError:
|
||||||
msg = 'Could not open license file. Please make sure it is in your config volume.'
|
msg = 'Could not open license file. Please make sure it is in your config volume.'
|
||||||
raise LicenseError(msg)
|
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))
|
return os.path.exists(os.path.join(self.config_volume, filename))
|
||||||
|
|
||||||
def get_volume_file(self, filename, mode='r'):
|
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):
|
def save_volume_file(self, filename, flask_file):
|
||||||
filepath = os.path.join(self.config_volume, filename)
|
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))
|
self._update_secret_file(self.yaml_filename, get_yaml(config_obj))
|
||||||
super(KubernetesConfigProvider, self).save_config(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):
|
def save_volume_file(self, filename, flask_file):
|
||||||
filepath = super(KubernetesConfigProvider, self).save_volume_file(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
|
import json
|
||||||
from StringIO import StringIO
|
import io
|
||||||
|
|
||||||
from util.config.provider.baseprovider import BaseProvider
|
from util.config.provider.baseprovider import BaseProvider
|
||||||
|
|
||||||
|
@ -46,11 +46,14 @@ class TestConfigProvider(BaseProvider):
|
||||||
def save_volume_file(self, filename, flask_file):
|
def save_volume_file(self, filename, flask_file):
|
||||||
self.files[filename] = ''
|
self.files[filename] = ''
|
||||||
|
|
||||||
|
def write_volume_file(self, filename, contents):
|
||||||
|
self.files[filename] = contents
|
||||||
|
|
||||||
def get_volume_file(self, filename, mode='r'):
|
def get_volume_file(self, filename, mode='r'):
|
||||||
if filename in REAL_FILES:
|
if filename in REAL_FILES:
|
||||||
return open(filename, mode=mode)
|
return open(filename, mode=mode)
|
||||||
|
|
||||||
return StringIO(self.files[filename])
|
return io.BytesIO(self.files[filename])
|
||||||
|
|
||||||
def requires_restart(self, app_config):
|
def requires_restart(self, app_config):
|
||||||
return False
|
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