From d080ca2cc6cb482b5e20aab4fb02e8f3a10272f3 Mon Sep 17 00:00:00 2001 From: Sam Chow Date: Mon, 14 May 2018 15:45:26 -0400 Subject: [PATCH] Create webpack config for config app further improve developer morale get initial angular loading Add remote css to config index Starts work to port endpoints into config app Add the api blueprint --- config_app/Procfile | 2 +- config_app/app.py | 3 +- config_app/application.py | 10 +- config_app/config_endpoints/api/__init__.py | 151 ++ config_app/config_endpoints/common.py | 363 +++- config_app/config_endpoints/setup_web.py | 43 +- config_app/js/components/file-upload-box.js | 172 ++ config_app/js/config-app.module.ts | 45 + .../config-bool-field.html | 8 + .../config-certificates-field.html | 76 + .../config-contact-field.html | 46 + .../config-contacts-field.html | 4 + .../config-file-field.html | 13 + .../config-list-field.html | 17 + .../config-map-field.html | 20 + .../config-numeric-field.html | 6 + .../config-parsed-field.html | 1 + .../config-service-key-field.html | 29 + .../config-setup-tool.html | 1656 ++++++++++++++++ .../config-string-field.html | 10 + .../config-string-list-field.html | 6 + .../config-variable-field.html | 10 + .../core-config-setup/config-setup-tool.html | 1657 +++++++++++++++++ .../js/core-config-setup/core-config-setup.js | 1454 +++++++++++++++ config_app/js/main.ts | 36 + config_app/js/services/api-service.js | 332 ++++ config_app/js/services/container-service.js | 45 + config_app/js/services/cookie-service.js | 23 + config_app/js/services/features-config.js | 91 + config_app/js/services/user-service.js | 217 +++ config_app/js/services/util-service.js | 83 + config_app/js/setup/setup.component.js | 332 ++++ config_app/js/setup/setup.html | 311 ++++ config_app/static/css/core-ui.css | 1500 +++++++++++++++ .../static/lib/angular-file-upload.min.js | 2 + config_app/templates/config_index.html | 12 - config_app/templates/index.html | 51 + config_app/util/__init__.py | 0 config_app/util/baseprovider.py | 128 ++ config_app/util/config.py | 21 + config_app/util/fileprovider.py | 60 + config_app/web.py | 4 +- config_app/webpack.config.js | 60 + endpoints/common.py | 6 +- local-config-app.sh | 10 +- package.json | 5 +- static/configappjs/index.js | 5 - web.py | 8 - webpack.config.js | 5 +- 49 files changed, 8996 insertions(+), 153 deletions(-) create mode 100644 config_app/config_endpoints/api/__init__.py create mode 100644 config_app/js/components/file-upload-box.js create mode 100644 config_app/js/config-app.module.ts create mode 100644 config_app/js/config-field-templates/config-bool-field.html create mode 100644 config_app/js/config-field-templates/config-certificates-field.html create mode 100644 config_app/js/config-field-templates/config-contact-field.html create mode 100644 config_app/js/config-field-templates/config-contacts-field.html create mode 100644 config_app/js/config-field-templates/config-file-field.html create mode 100644 config_app/js/config-field-templates/config-list-field.html create mode 100644 config_app/js/config-field-templates/config-map-field.html create mode 100644 config_app/js/config-field-templates/config-numeric-field.html create mode 100644 config_app/js/config-field-templates/config-parsed-field.html create mode 100644 config_app/js/config-field-templates/config-service-key-field.html create mode 100644 config_app/js/config-field-templates/config-setup-tool.html create mode 100644 config_app/js/config-field-templates/config-string-field.html create mode 100644 config_app/js/config-field-templates/config-string-list-field.html create mode 100644 config_app/js/config-field-templates/config-variable-field.html create mode 100644 config_app/js/core-config-setup/config-setup-tool.html create mode 100644 config_app/js/core-config-setup/core-config-setup.js create mode 100644 config_app/js/main.ts create mode 100644 config_app/js/services/api-service.js create mode 100644 config_app/js/services/container-service.js create mode 100644 config_app/js/services/cookie-service.js create mode 100644 config_app/js/services/features-config.js create mode 100644 config_app/js/services/user-service.js create mode 100644 config_app/js/services/util-service.js create mode 100644 config_app/js/setup/setup.component.js create mode 100644 config_app/js/setup/setup.html create mode 100644 config_app/static/css/core-ui.css create mode 100644 config_app/static/lib/angular-file-upload.min.js delete mode 100644 config_app/templates/config_index.html create mode 100644 config_app/templates/index.html create mode 100644 config_app/util/__init__.py create mode 100644 config_app/util/baseprovider.py create mode 100644 config_app/util/config.py create mode 100644 config_app/util/fileprovider.py create mode 100644 config_app/webpack.config.js delete mode 100644 static/configappjs/index.js diff --git a/config_app/Procfile b/config_app/Procfile index 242c204d4..0ea2ba9c6 100644 --- a/config_app/Procfile +++ b/config_app/Procfile @@ -1,3 +1,3 @@ app: PYTHONPATH="../" gunicorn -c conf/gunicorn_local.py application:application -# webpack: npm run watch +# webpack: npm run watch-config-app diff --git a/config_app/app.py b/config_app/app.py index ba09f5713..811f51457 100644 --- a/config_app/app.py +++ b/config_app/app.py @@ -1,3 +1,4 @@ -from flask import Flask, request, Request +from flask import Flask app = Flask(__name__) + diff --git a/config_app/application.py b/config_app/application.py index a4d231092..9f4249e00 100644 --- a/config_app/application.py +++ b/config_app/application.py @@ -1,15 +1,9 @@ -# import os -# import logging -# import logging.config - -# from util.log import logfile_path from app import app as application - # Bind all of the blueprints import web if __name__ == '__main__': - logging.config.fileConfig(logfile_path(debug=True), disable_existing_loggers=False) - application.run(port=5000, debug=True, threaded=True, host='0.0.0.0') + logging.config.fileConfig(logfile_path(debug=True), disable_existing_loggers=False) + application.run(port=5000, debug=True, threaded=True, host='0.0.0.0') diff --git a/config_app/config_endpoints/api/__init__.py b/config_app/config_endpoints/api/__init__.py new file mode 100644 index 000000000..67580475a --- /dev/null +++ b/config_app/config_endpoints/api/__init__.py @@ -0,0 +1,151 @@ +import logging + +from config_app import app +from config_app.util.config import config_provider + +from flask import Blueprint, request, session +from flask_restful import Resource, abort, Api, reqparse +from flask_restful.utils.cors import crossdomain + +from functools import partial, wraps + + +logger = logging.getLogger(__name__) +api_bp = Blueprint('api', __name__) + +CROSS_DOMAIN_HEADERS = ['Authorization', 'Content-Type', 'X-Requested-With'] + + +class ApiExceptionHandlingApi(Api): + @crossdomain(origin='*', headers=CROSS_DOMAIN_HEADERS) + def handle_error(self, error): + print('HANDLING ERROR IN API') + return super(ApiExceptionHandlingApi, self).handle_error(error) + + +api = ApiExceptionHandlingApi() + + +class HelloWorld(Resource): + def get(self): + print("hit the dummy endpoint") + return {'hello': 'world'} + + +api.add_resource(HelloWorld, '/') + + + +def verify_not_prod(func): + @add_method_metadata('enterprise_only', True) + def wrapped(*args, **kwargs): + # Verify that we are not running on a production (i.e. hosted) stack. If so, we fail. + # This should never happen (because of the feature-flag on SUPER_USERS), but we want to be + # absolutely sure. + # if app.config['SERVER_HOSTNAME'].find('quay.io') >= 0: + # TODO(config_port) fixme + if False: + logger.error('!!! Super user method called IN PRODUCTION !!!') + raise StandardError() + + return func(*args, **kwargs) + + return wrapped + + +def resource(*urls, **kwargs): + def wrapper(api_resource): + if not api_resource: + return None + + api_resource.registered = True + api.add_resource(api_resource, *urls, **kwargs) + return api_resource + + return wrapper + + +class ApiResource(Resource): + registered = False + method_decorators = [] + + def options(self): + return None, 200 + + +def add_method_metadata(name, value): + def modifier(func): + if func is None: + return None + + if '__api_metadata' not in dir(func): + func.__api_metadata = {} + func.__api_metadata[name] = value + return func + + return modifier + + +def method_metadata(func, name): + if func is None: + return None + + if '__api_metadata' in dir(func): + return func.__api_metadata.get(name, None) + return None + + +def no_cache(f): + @wraps(f) + def add_no_cache(*args, **kwargs): + response = f(*args, **kwargs) + if response is not None: + response.headers['Cache-Control'] = 'no-cache, no-store, must-revalidate' + return response + return add_no_cache + + +nickname = partial(add_method_metadata, 'nickname') + +api.init_app(api_bp) +# api.decorators = [csrf_protect(), +# crossdomain(origin='*', headers=CROSS_DOMAIN_HEADERS), +# process_oauth, time_decorator(api_bp.name, metric_queue), +# require_xhr_from_browser] + + + + +@resource('/v1/superuser/config') +class SuperUserConfig(ApiResource): + """ Resource for fetching and updating the current configuration, if any. """ + schemas = { + 'UpdateConfig': { + 'type': 'object', + 'description': 'Updates the YAML config file', + 'required': [ + 'config', + 'hostname' + ], + 'properties': { + 'config': { + 'type': 'object' + }, + 'hostname': { + 'type': 'string' + }, + 'password': { + 'type': 'string' + }, + }, + }, + } + + @verify_not_prod + @nickname('scGetConfig') + def get(self): + """ Returns the currently defined configuration, if any. """ + config_object = config_provider.get_config() + return { + 'config': config_object + } diff --git a/config_app/config_endpoints/common.py b/config_app/config_endpoints/common.py index 1bcdfb03d..1378f0209 100644 --- a/config_app/config_endpoints/common.py +++ b/config_app/config_endpoints/common.py @@ -1,78 +1,305 @@ -from flask import make_response, render_template, request, session +import logging +import os +import re +import sys +from collections import OrderedDict +from cachetools import lru_cache -def render_page_template(name, route_data=None, **kwargs): +from flask import make_response, render_template +from flask_restful import reqparse + +from config_app.config_endpoints.api import method_metadata +from config_app.app import app + + +def truthy_bool(param): + return param not in {False, 'false', 'False', '0', 'FALSE', '', 'null'} + + +DEFAULT_JS_BUNDLE_NAME = 'configapp' +PARAM_REGEX = re.compile(r'<([^:>]+:)*([\w]+)>') +logger = logging.getLogger(__name__) +TYPE_CONVERTER = { + truthy_bool: 'boolean', + str: 'string', + basestring: 'string', + reqparse.text_type: 'string', + int: 'integer', +} + + +def _list_files(path, extension, contains=""): + """ Returns a list of all the files with the given extension found under the given path. """ + + def matches(f): + return os.path.splitext(f)[1] == '.' + extension and contains in os.path.splitext(f)[0] + + def join_path(dp, f): + # Remove the static/ prefix. It is added in the template. + return os.path.join(dp, f)[len('static/'):] + + filepath = os.path.join('static/', path) + return [join_path(dp, f) for dp, _, files in os.walk(filepath) for f in files if matches(f)] + + +def render_page_template(name, route_data=None, js_bundle_name=DEFAULT_JS_BUNDLE_NAME, **kwargs): """ Renders the page template with the given name as the response and returns its contents. """ - # main_scripts = _list_files('build', 'js', js_bundle_name) - # - # use_cdn = app.config.get('USE_CDN', True) - # if request.args.get('use_cdn') is not None: - # use_cdn = request.args.get('use_cdn') == 'true' - # - # external_styles = get_external_css(local=not use_cdn) - # external_scripts = get_external_javascript(local=not use_cdn) - # - # # Add Stripe checkout if billing is enabled. - # if features.BILLING: - # external_scripts.append('//checkout.stripe.com/checkout.js') - # - # def get_external_login_config(): - # login_config = [] - # for login_service in oauth_login.services: - # login_config.append({ - # 'id': login_service.service_id(), - # 'title': login_service.service_name(), - # 'config': login_service.get_public_config(), - # 'icon': login_service.get_icon(), - # }) - # - # return login_config - # - # def get_oauth_config(): - # oauth_config = {} - # for oauth_app in oauth_apps: - # oauth_config[oauth_app.key_name] = oauth_app.get_public_config() - # - # return oauth_config - # - # contact_href = None - # if len(app.config.get('CONTACT_INFO', [])) == 1: - # contact_href = app.config['CONTACT_INFO'][0] - # - # version_number = '' - # if not features.BILLING: - # version_number = 'Quay %s' % __version__ - # - # scopes_set = {scope.scope: scope._asdict() for scope in scopes.app_scopes(app.config).values()} + main_scripts = _list_files('build', 'js', js_bundle_name) contents = render_template(name, route_data=route_data, - # external_styles=external_styles, - # external_scripts=external_scripts, - # main_scripts=main_scripts, - # feature_set=features.get_features(), - # config_set=frontend_visible_config(app.config), - # oauth_set=get_oauth_config(), - # external_login_set=get_external_login_config(), - # scope_set=scopes_set, - # vuln_priority_set=PRIORITY_LEVELS, - # enterprise_logo=app.config.get('ENTERPRISE_LOGO_URL', ''), - # mixpanel_key=app.config.get('MIXPANEL_KEY', ''), - # munchkin_key=app.config.get('MARKETO_MUNCHKIN_ID', ''), - # recaptcha_key=app.config.get('RECAPTCHA_SITE_KEY', ''), - # google_tagmanager_key=app.config.get('GOOGLE_TAGMANAGER_KEY', ''), - # google_anaytics_key=app.config.get('GOOGLE_ANALYTICS_KEY', ''), - # sentry_public_dsn=app.config.get('SENTRY_PUBLIC_DSN', ''), - # is_debug=str(app.config.get('DEBUGGING', False)).lower(), - # show_chat=features.SUPPORT_CHAT, - # aci_conversion=features.ACI_CONVERSION, - # has_billing=features.BILLING, - # contact_href=contact_href, - # hostname=app.config['SERVER_HOSTNAME'], - # preferred_scheme=app.config['PREFERRED_URL_SCHEME'], - # version_number=version_number, - # current_year=datetime.datetime.now().year, + main_scripts=main_scripts, **kwargs) resp = make_response(contents) resp.headers['X-FRAME-OPTIONS'] = 'DENY' return resp + + +def fully_qualified_name(method_view_class): + return '%s.%s' % (method_view_class.__module__, method_view_class.__name__) + + +# @lru_cache(maxsize=1) +def generate_route_data(): + include_internal = True + compact = True + + def swagger_parameter(name, description, kind='path', param_type='string', required=True, + enum=None, schema=None): + # https://github.com/swagger-api/swagger-spec/blob/master/versions/2.0.md#parameterObject + parameter_info = { + 'name': name, + 'in': kind, + 'required': required + } + + if schema: + parameter_info['schema'] = { + '$ref': '#/definitions/%s' % schema + } + else: + parameter_info['type'] = param_type + + if enum is not None and len(list(enum)) > 0: + parameter_info['enum'] = list(enum) + + return parameter_info + + paths = {} + models = {} + tags = [] + tags_added = set() + operation_ids = set() + + print('APP URL MAp:') + print(app.url_map) + for rule in app.url_map.iter_rules(): + endpoint_method = app.view_functions[rule.endpoint] + + # Verify that we have a view class for this API method. + if not 'view_class' in dir(endpoint_method): + continue + + view_class = endpoint_method.view_class + + # Hide the class if it is internal. + internal = method_metadata(view_class, 'internal') + if not include_internal and internal: + continue + + # Build the tag. + parts = fully_qualified_name(view_class).split('.') + tag_name = parts[-2] + if not tag_name in tags_added: + tags_added.add(tag_name) + tags.append({ + 'name': tag_name, + 'description': (sys.modules[view_class.__module__].__doc__ or '').strip() + }) + + # Build the Swagger data for the path. + swagger_path = PARAM_REGEX.sub(r'{\2}', rule.rule) + full_name = fully_qualified_name(view_class) + path_swagger = { + 'x-name': full_name, + 'x-path': swagger_path, + 'x-tag': tag_name + } + + related_user_res = method_metadata(view_class, 'related_user_resource') + if related_user_res is not None: + path_swagger['x-user-related'] = fully_qualified_name(related_user_res) + + paths[swagger_path] = path_swagger + + # Add any global path parameters. + param_data_map = view_class.__api_path_params if '__api_path_params' in dir(view_class) else {} + if param_data_map: + path_parameters_swagger = [] + for path_parameter in param_data_map: + description = param_data_map[path_parameter].get('description') + path_parameters_swagger.append(swagger_parameter(path_parameter, description)) + + path_swagger['parameters'] = path_parameters_swagger + + # Add the individual HTTP operations. + method_names = list(rule.methods.difference(['HEAD', 'OPTIONS'])) + for method_name in method_names: + # https://github.com/swagger-api/swagger-spec/blob/master/versions/2.0.md#operation-object + method = getattr(view_class, method_name.lower(), None) + if method is None: + logger.debug('Unable to find method for %s in class %s', method_name, view_class) + continue + + operationId = method_metadata(method, 'nickname') + operation_swagger = { + 'operationId': operationId, + 'parameters': [], + } + + if operationId is None: + continue + + if operationId in operation_ids: + raise Exception('Duplicate operation Id: %s' % operationId) + + operation_ids.add(operationId) + + # Mark the method as internal. + internal = method_metadata(method, 'internal') + if internal is not None: + operation_swagger['x-internal'] = True + + if include_internal: + requires_fresh_login = method_metadata(method, 'requires_fresh_login') + if requires_fresh_login is not None: + operation_swagger['x-requires-fresh-login'] = True + + # Add the path parameters. + if rule.arguments: + for path_parameter in rule.arguments: + description = param_data_map.get(path_parameter, {}).get('description') + operation_swagger['parameters'].append(swagger_parameter(path_parameter, description)) + + # Add the query parameters. + if '__api_query_params' in dir(method): + for query_parameter_info in method.__api_query_params: + name = query_parameter_info['name'] + description = query_parameter_info['help'] + param_type = TYPE_CONVERTER[query_parameter_info['type']] + required = query_parameter_info['required'] + + operation_swagger['parameters'].append( + swagger_parameter(name, description, kind='query', + param_type=param_type, + required=required, + enum=query_parameter_info['choices'])) + + # Add the OAuth security block. + # https://github.com/swagger-api/swagger-spec/blob/master/versions/2.0.md#securityRequirementObject + scope = method_metadata(method, 'oauth2_scope') + if scope and not compact: + operation_swagger['security'] = [{'oauth2_implicit': [scope.scope]}] + + # Add the responses block. + # https://github.com/swagger-api/swagger-spec/blob/master/versions/2.0.md#responsesObject + response_schema_name = method_metadata(method, 'response_schema') + if not compact: + if response_schema_name: + models[response_schema_name] = view_class.schemas[response_schema_name] + + models['ApiError'] = { + 'type': 'object', + 'properties': { + 'status': { + 'type': 'integer', + 'description': 'Status code of the response.' + }, + 'type': { + 'type': 'string', + 'description': 'Reference to the type of the error.' + }, + 'detail': { + 'type': 'string', + 'description': 'Details about the specific instance of the error.' + }, + 'title': { + 'type': 'string', + 'description': 'Unique error code to identify the type of error.' + }, + 'error_message': { + 'type': 'string', + 'description': 'Deprecated; alias for detail' + }, + 'error_type': { + 'type': 'string', + 'description': 'Deprecated; alias for detail' + } + }, + 'required': [ + 'status', + 'type', + 'title', + ] + } + + responses = { + '400': { + 'description': 'Bad Request', + }, + + '401': { + 'description': 'Session required', + }, + + '403': { + 'description': 'Unauthorized access', + }, + + '404': { + 'description': 'Not found', + }, + } + + for _, body in responses.items(): + body['schema'] = {'$ref': '#/definitions/ApiError'} + + if method_name == 'DELETE': + responses['204'] = { + 'description': 'Deleted' + } + elif method_name == 'POST': + responses['201'] = { + 'description': 'Successful creation' + } + else: + responses['200'] = { + 'description': 'Successful invocation' + } + + if response_schema_name: + responses['200']['schema'] = { + '$ref': '#/definitions/%s' % response_schema_name + } + + operation_swagger['responses'] = responses + + # Add the request block. + request_schema_name = method_metadata(method, 'request_schema') + if request_schema_name and not compact: + models[request_schema_name] = view_class.schemas[request_schema_name] + + operation_swagger['parameters'].append( + swagger_parameter('body', 'Request body contents.', kind='body', + schema=request_schema_name)) + + # Add the operation to the parent path. + if not internal or (internal and include_internal): + path_swagger[method_name.lower()] = operation_swagger + + tags.sort(key=lambda t: t['name']) + paths = OrderedDict(sorted(paths.items(), key=lambda p: p[1]['x-tag'])) + + if compact: + return {'paths': paths} diff --git a/config_app/config_endpoints/setup_web.py b/config_app/config_endpoints/setup_web.py index eb07ae01d..90693d037 100644 --- a/config_app/config_endpoints/setup_web.py +++ b/config_app/config_endpoints/setup_web.py @@ -1,47 +1,18 @@ -import os -import json -import logging - -from datetime import timedelta, datetime - -from cachetools import lru_cache -# from flask import (abort, redirect, request, url_for, make_response, Response, render_template, -# Blueprint, jsonify, send_file, session) from flask import Blueprint -# from flask_login import current_user - - -from app import (app) -# from endpoints.api.discovery import swagger_route_data from common import render_page_template +from config_app.config_endpoints.common import generate_route_data from util.cache import no_cache - - -# @lru_cache(maxsize=1) -# def _get_route_data(): -# return swagger_route_data(include_internal=True, compact=True) - - -def render_page_template_with_routedata(name, *args, **kwargs): - return render_page_template(name, *args, **kwargs) - -# Capture the unverified SSL errors. -logger = logging.getLogger(__name__) -logging.captureWarnings(True) - setup_web = Blueprint('setup_web', __name__, template_folder='templates') -# STATUS_TAGS = app.config['STATUS_TAGS'] + +def render_page_template_with_routedata(name, *args, **kwargs): + return render_page_template(name, generate_route_data(), *args, **kwargs) + @setup_web.route('/', methods=['GET'], defaults={'path': ''}) @no_cache def index(path, **kwargs): - return render_page_template_with_routedata('config_index.html', js_bundle_name='configapp', **kwargs) + return render_page_template_with_routedata('index.html', js_bundle_name='configapp', **kwargs) + -@setup_web.errorhandler(404) -@setup_web.route('/404', methods=['GET']) -def not_found_error_display(e = None): - resp = index('', error_code=404, error_info=dict(reason='notfound')) - resp.status_code = 404 - return resp diff --git a/config_app/js/components/file-upload-box.js b/config_app/js/components/file-upload-box.js new file mode 100644 index 000000000..7005c21ed --- /dev/null +++ b/config_app/js/components/file-upload-box.js @@ -0,0 +1,172 @@ +/** + * An element which adds a stylize box for uploading a file. + */ +angular.module('quay-config').directive('fileUploadBox', function () { + var directiveDefinitionObject = { + priority: 0, + templateUrl: '/static/directives/file-upload-box.html', + replace: false, + transclude: true, + restrict: 'C', + scope: { + 'selectMessage': '@selectMessage', + + 'filesSelected': '&filesSelected', + 'filesCleared': '&filesCleared', + 'filesValidated': '&filesValidated', + + 'extensions': '= $scope.selectedFiles.length) { + callback(true, fileIds); + return; + } + + // For the current file, retrieve a file-drop URL from the API for the file. + var currentFile = $scope.selectedFiles[currentIndex]; + var mimeType = currentFile.type || 'application/octet-stream'; + var data = { + 'mimeType': mimeType + }; + + $scope.currentlyUploadingFile = currentFile; + $scope.uploadProgress = 0; + + ApiService.getFiledropUrl(data).then(function(resp) { + // Perform the upload. + conductUpload(currentFile, resp.url, resp.file_id, mimeType, progressCb, doneCb); + }, function() { + callback(false, 'Could not retrieve upload URL'); + }); + }; + + // Start the uploading. + $scope.state = 'uploading'; + performFileUpload(); + }; + + $scope.handleFilesChanged = function(files) { + if ($scope.state == 'uploading') { return; } + + $scope.message = null; + $scope.selectedFiles = files; + + if (files.length == 0) { + $scope.state = 'clear'; + $scope.filesCleared(); + } else { + for (var i = 0; i < files.length; ++i) { + if (files[i].size > MAX_FILE_SIZE) { + $scope.state = 'error'; + $scope.message = 'File ' + files[i].name + ' is larger than the maximum file ' + + 'size of ' + MAX_FILE_SIZE_MB + ' MB'; + return; + } + } + + $scope.state = 'checking'; + $scope.filesSelected({ + 'files': files, + 'callback': function(status, message) { + $scope.state = status ? 'okay' : 'error'; + $scope.message = message; + + if (status) { + $scope.filesValidated({ + 'files': files, + 'uploadFiles': uploadFiles + }); + } + } + }); + } + }; + + $scope.getAccepts = function(extensions) { + if (!extensions || !extensions.length) { + return '*'; + } + + return extensions.join(','); + }; + + $scope.$watch('reset', function(reset) { + if (reset) { + $scope.state = 'clear'; + $element.find('#file-drop-' + $scope.boxId).parent().trigger('reset'); + } + }); + } + }; + return directiveDefinitionObject; +}); \ No newline at end of file diff --git a/config_app/js/config-app.module.ts b/config_app/js/config-app.module.ts new file mode 100644 index 000000000..59fe1bf20 --- /dev/null +++ b/config_app/js/config-app.module.ts @@ -0,0 +1,45 @@ +import { NgModule } from 'ng-metadata/core'; +import * as restangular from 'restangular'; + +const quayDependencies: string[] = [ + 'restangular', + 'ngCookies', + 'angularFileUpload', + 'ngSanitize' +]; + +@NgModule(({ + imports: quayDependencies, + declarations: [], + providers: [ + provideConfig, + ] +})) +class DependencyConfig{} + + +provideConfig.$inject = [ + '$provide', + '$injector', + '$compileProvider', + 'RestangularProvider', +]; + +function provideConfig($provide: ng.auto.IProvideService, + $injector: ng.auto.IInjectorService, + $compileProvider: ng.ICompileProvider, + RestangularProvider: any): void { + + // Configure the API provider. + RestangularProvider.setBaseUrl('/api/v1/'); + + console.log('i'); +} + + +@NgModule({ + imports: [ DependencyConfig ], + declarations: [], + providers: [] +}) +export class ConfigAppModule {} diff --git a/config_app/js/config-field-templates/config-bool-field.html b/config_app/js/config-field-templates/config-bool-field.html new file mode 100644 index 000000000..190698290 --- /dev/null +++ b/config_app/js/config-field-templates/config-bool-field.html @@ -0,0 +1,8 @@ +
+
+ +
+
diff --git a/config_app/js/config-field-templates/config-certificates-field.html b/config_app/js/config-field-templates/config-certificates-field.html new file mode 100644 index 000000000..f20e4c459 --- /dev/null +++ b/config_app/js/config-field-templates/config-certificates-field.html @@ -0,0 +1,76 @@ +
+
+ +
+ extra_ca_certs is a single file and cannot be processed by this tool. If a valid and appended list of certificates, they will be installed on container startup. +
+ +
+
+

This section lists any custom or self-signed SSL certificates that are installed in the container on startup after being read from the extra_ca_certs directory in the configuration volume. +

+

+ Custom certificates are typically used in place of publicly signed certificates for corporate-internal services. +

+

Please make sure that all custom names used for downstream services (such as Clair) are listed in the certificates below.

+
+ + + + + + +
Upload certificates: +
+
+ + + + + + + + + + + + + + +
Certificate FilenameStatusNames Handled
{{ certificate.path }} +
+ + Error: {{ certificate.error }} +
+
+ + Certificate is expired +
+
+ + Certificate is valid +
+
+
(None)
+ {{ name }} +
+ + + Delete Certificate + + +
+
+
+ Uploading, validating and updating certificate(s) +
+
+
No custom certificates found.
+
+
+
+
\ No newline at end of file diff --git a/config_app/js/config-field-templates/config-contact-field.html b/config_app/js/config-field-templates/config-contact-field.html new file mode 100644 index 000000000..58cdea0c4 --- /dev/null +++ b/config_app/js/config-field-templates/config-contact-field.html @@ -0,0 +1,46 @@ +
+ + + + + +
+ + +
+ +
+
+
diff --git a/config_app/js/config-field-templates/config-contacts-field.html b/config_app/js/config-field-templates/config-contacts-field.html new file mode 100644 index 000000000..40762934c --- /dev/null +++ b/config_app/js/config-field-templates/config-contacts-field.html @@ -0,0 +1,4 @@ +
+
+
+
diff --git a/config_app/js/config-field-templates/config-file-field.html b/config_app/js/config-field-templates/config-file-field.html new file mode 100644 index 000000000..11c4227f7 --- /dev/null +++ b/config_app/js/config-field-templates/config-file-field.html @@ -0,0 +1,13 @@ +
+ + + /conf/stack/{{ filename }} + Select a replacement file: + + Please select a file to upload as {{ filename }}: + + + + Uploading file as {{ filename }}... {{ uploadProgress }}% + +
diff --git a/config_app/js/config-field-templates/config-list-field.html b/config_app/js/config-field-templates/config-list-field.html new file mode 100644 index 000000000..9918e9a07 --- /dev/null +++ b/config_app/js/config-field-templates/config-list-field.html @@ -0,0 +1,17 @@ +
+
    +
  • + {{ item }} + + Remove + +
  • +
+ No {{ itemTitle }}s defined +
+ + +
+
diff --git a/config_app/js/config-field-templates/config-map-field.html b/config_app/js/config-field-templates/config-map-field.html new file mode 100644 index 000000000..84f086052 --- /dev/null +++ b/config_app/js/config-field-templates/config-map-field.html @@ -0,0 +1,20 @@ +
+ + + + + + +
{{ key }}{{ value }} + Remove +
+ No entries defined +
+ Add Key-Value: + + + +
+
diff --git a/config_app/js/config-field-templates/config-numeric-field.html b/config_app/js/config-field-templates/config-numeric-field.html new file mode 100644 index 000000000..8c25a2fea --- /dev/null +++ b/config_app/js/config-field-templates/config-numeric-field.html @@ -0,0 +1,6 @@ +
+
+ +
+
diff --git a/config_app/js/config-field-templates/config-parsed-field.html b/config_app/js/config-field-templates/config-parsed-field.html new file mode 100644 index 000000000..766b0a8a2 --- /dev/null +++ b/config_app/js/config-field-templates/config-parsed-field.html @@ -0,0 +1 @@ +
diff --git a/config_app/js/config-field-templates/config-service-key-field.html b/config_app/js/config-field-templates/config-service-key-field.html new file mode 100644 index 000000000..52b7c1187 --- /dev/null +++ b/config_app/js/config-field-templates/config-service-key-field.html @@ -0,0 +1,29 @@ +
+ +
+ + +
+ Could not load service keys +
+ + +
+
+ + Valid key for service {{ serviceName }} exists +
+
+ No valid key found for service {{ serviceName }} + Create Key +
+
+ + + + +
+
diff --git a/config_app/js/config-field-templates/config-setup-tool.html b/config_app/js/config-field-templates/config-setup-tool.html new file mode 100644 index 000000000..629e3b45f --- /dev/null +++ b/config_app/js/config-field-templates/config-setup-tool.html @@ -0,0 +1,1656 @@ +
+
+
+
+ + +
+
+ Custom SSL Certificates +
+
+
+
+
+ + +
+
+ Basic Configuration +
+
+ + + + + + + + + + +
Enterprise Logo URL: + +
+ Enter the full URL to your company's logo. +
+
+ +
Contact Information: + +
+ Information to show in the Contact Page. If none specified, CoreOS contact information + is displayed. +
+
+
+
+ + +
+
+ Server Configuration +
+
+ + + + + + + + + +
Server Hostname: + +
+ The HTTP host (and optionally the port number if a non-standard HTTP/HTTPS port) of the location + where the registry will be accessible on the network +
+
TLS: + + +
+ Running without TLS should not be used for production workloads! +
+ +
+ Terminating TLS outside of Quay Enterprise can result in unusual behavior if the external load balancer is not + configured properly. This option is not recommended for simple setups. Please contact support + if you encounter problems while using this option. +
+ +
+ Enabling TLS also enables HTTP Strict Transport Security.
+ This prevents downgrade attacks and cookie theft, but browsers will reject all future insecure connections on this hostname. +
+ + + + + + + + + + +
Certificate: + +
+ The certificate must be in PEM format. +
+
Private key: + +
+
+ +
+
+ + +
+
+ Data Consistency Settings +
+
+
+

Relax constraints on consistency guarantees for specific operations + to enable higher performance and availability. +

+
+ + + + +
+
+ Allow repository pulls even if audit logging fails. +
+ If enabled, failures to write to the audit log will fallback from + the database to the standard logger for registry pulls. +
+
+
+
+
+ + +
+
+ Time Machine +
+
+
+

Time machine keeps older copies of tags within a repository for the configured period + of time, after which they are garbage collected. This allows users to + revert tags to older images in case they accidentally pushed a broken image. It is + highly recommended to have time machine enabled, but it does take a bit more space + in storage. +

+
+ + + + + + + + + + + + + + +
Allowed expiration periods: + +
+ The expiration periods allowed for configuration. The default tag expiration *must* be in this list. +
+
Default expiration period: + +
+ The default tag expiration period for all namespaces (users and organizations). Must be expressed in a duration string form: 30m, 1h, 1d, 2w. +
+
Allow users to select expiration: +
+ Enable Expiration Configuration +
+ If enabled, users will be able to select the tag expiration duration for the namespace(s) they + administrate, from the configured list of options. +
+
+
+
+
+ + +
+
+ redis +
+
+
+

A redis key-value store is required for real-time events and build logs.

+
+ + + + + + + + + + + + + + +
Redis Hostname: + +
Redis port: + +
+ Access to this port and hostname must be allowed from all hosts running + the enterprise registry +
+
Redis password: + +
+
+
+ + +
+
+ Registry Storage +
+
+
+

+ Registry images can be stored either locally or in a remote storage system. + A remote storage system is required for high-availability systems. +

+ +
+ Enable Storage Replication +
+ If enabled, replicates storage to other regions. See documentation for more information. +
+
+ +
+ + + + + + + + + + + + + + + + + + + + + +
Location ID: + +
+ {{ sc.location }} +
+
+ {{ storageConfigError[$index].location }} +
+ +
Set Default: +
+ Replicate to storage engine by default +
+
Storage Engine: + + +
+ {{ storageConfigError[$index].engine }} +
+
{{ field.title }}: + + + + {{ field.placeholder }} + + +
+ +
+
+ {{ field.help_text }} +
+
+ See Documentation for more information +
+
+
+ + +
+
+
+ + +
+
+ Action Log Rotation and Archiving +
+
+
+

+ All actions performed in are automatically logged. These logs are stored in a database table, which can become quite large. + Enabling log rotation and archiving will move all logs older than 30 days into storage. +

+
+
+ Enable Action Log Rotation +
+ + + + + + + + + + +
Storage location: + +
+ The storage location in which to place archived action logs. Logs will only be archived to this single location. +
+
Storage path: + +
+ The path under the configured storage engine in which to place the archived logs in JSON form. +
+
+
+ + +
+
+ Security Scanner +
+
+
+

If enabled, all images pushed to Quay will be scanned via the external security scanning service, with vulnerability information available in the UI and API, as well + as async notification support. +

+
+ +
+ Enable Security Scanning +
+
+ A scanner compliant with the Quay Security Scanning API must be running to use this feature. Documentation on running Clair can be found at Running Clair Security Scanner. +
+ + + + + + + + + + +
Authentication Key: + +
+ The security scanning service requires an authorized service key to speak to Quay. Once setup, the key + can be managed in the Service Keys panel under the Super User Admin Panel. +
+
Security Scanner Endpoint: + +
+ The HTTP URL at which the security scanner is running. +
+
+ Is the security scanner behind a domain signed with a self-signed TLS certificate? If so, please make sure to register your SSL CA in the custom certificates panel above. +
+
+
+
+ + +
+
+ Application Registry +
+
+
+

If enabled, an additional registry API will be available for managing applications (Kubernetes manifests, Helm charts) via the App Registry specification. A great place to get started is to install the Helm Registry Plugin. +

+ +
+ Enable App Registry +
+
+
+ + +
+
+ BitTorrent-based download +
+
+
+

If enabled, all images in the registry can be downloaded using the quayctl tool via the BitTorrent protocol. A JWT-compatible BitTorrent tracker such as Chihaya must be run. +

+ +
+ Enable BitTorrent downloads +
+ + + + + + +
Announce URL: + +
+ The HTTP URL at which the torrents should be announced. A JWT-compatible tracker such as Chihaya must be run to ensure proper security. Documentation on running Chihaya with + this support can be found at Running Chihaya for Quay Enterprise. +
+
+
+
+ + +
+
+ rkt Conversion +
+
+
+

If enabled, all images in the registry can be fetched via rkt fetch or any other AppC discovery-compliant implementation.

+
+ +
+ Enable ACI Conversion +
+ +
+ Documentation on generating these keys can be found at Generating ACI Signing Keys. +
+ + + + + + + + + + + + + + +
GPG2 Public Key File: + +
+ The certificate must be in PEM format. +
+
GPG2 Private Key File: + +
GPG2 Private Key Name: + +
+
+
+ + +
+
+ E-mail +
+
+
+

Valid e-mail server configuration is required for notification e-mails and the ability of + users to reset their passwords.

+
+ +
+ Enable E-mails +
+ + + + + + + + + + + + + + + + + + + + + + + +
SMTP Server: + > +
SMTP Server Port: + +
TLS: +
+ Require TLS +
+
Mail Sender: + +
+ E-mail address from which all e-mails are sent. If not specified, + support@quay.io will be used. +
+
Authentication: +
+ Requires Authentication +
+ + + + + + + + + + +
Username: + +
Password: + +
+
+
+
+ + +
+
+ Internal Authentication +
+
+
+

+ Authentication for the registry can be handled by either the registry itself, LDAP, Keystone, or external JWT endpoint. +

+

+ Additional external authentication providers (such as GitHub) can be used in addition for login into the UI. +

+
+ +
+
+ It is highly recommended to require encrypted client passwords. External passwords used in the Docker client will be stored in plaintext! + Enable this requirement now. +
+ +
+ Note: The "Require Encrypted Client Passwords" feature is currently enabled which will + prevent passwords from being saved as plaintext by the Docker client. +
+
+ + + + + + + + + + + + + + + + + + + +
Authentication: + +
Team synchronization: +
+ Enable Team Synchronization Support +
+
+ If enabled, organization administrators who are also superusers can set teams to have their membership synchronized with a backing group in {{ config.AUTHENTICATION_TYPE }}. +
+
Resynchronization duration: + +
+ The duration before a team must be re-synchronized. Must be expressed in a duration string form: 30m, 1h, 1d. +
+
Self-service team syncing setup: +
If enabled, this feature will allow *any organization administrator* to read the membership of any {{ config.AUTHENTICATION_TYPE }} group.
+
+ Allow non-superusers to enable and manage team syncing +
+
+ If enabled, non-superusers will be able to enable and manage team sycning on teams under organizations in which they are administrators. +
+
+ + + + + + + + + + + + + + + + + + + + + + + +
Keystone API Version: + +
Keystone Authentication URL: + +
+ The URL (starting with http or https) of the Keystone Server endpoint for auth. +
+
Keystone Administrator Username: + +
+ The username for the Keystone admin. +
+
Keystone Administrator Password: + +
+ The password for the Keystone admin. +
+
Keystone Administrator Tenant: + +
+ The tenant (project/group) that contains the administrator user. +
+
+ + +
+ JSON Web Token authentication allows your organization to provide an HTTP endpoint that + verifies user credentials on behalf of . +
+ Documentation + on the API required can be found here: https://github.com/coreos/jwt-auth-example. +
+ + + + + + + + + + + + + + + + + + + + + + +
Authentication Issuer: + +
+ The id of the issuer signing the JWT token. Must be unique to your organization. +
+
Public Key: + +
+ A certificate containing the public key portion of the key pair used to sign + the JSON Web Tokens. This file must be in PEM format. +
+
User Verification Endpoint: + +
+ The URL (starting with http or https) on the JWT authentication server for verifying username and password credentials. +
+ +
+ Credentials will be sent in the Authorization header as Basic Auth, and this endpoint should return 200 OK on success (or a 4** otherwise). +
+
User Query Endpoint: + +
+ The URL (starting with http or https) on the JWT authentication server for looking up + users based on a prefix query. This is optional. +
+ +
+ The prefix query will be sent as a query parameter with name query. +
+
User Lookup Endpoint: + +
+ The URL (starting with http or https) on the JWT authentication server for looking up + a user by username or email address. +
+ +
+ The username or email address will be sent as a query parameter with name username. +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
LDAP URI: + +
+ The full LDAP URI, including the ldap:// or ldaps:// prefix. +
+
Base DN: + +
+ A Distinguished Name path which forms the base path for looking up all LDAP records. +
+
+ Example: dc=my,dc=domain,dc=com +
+
User Relative DN: + +
+ A Distinguished Name path which forms the base path for looking up all user LDAP records, + relative to the Base DN defined above. +
+
+ Example: ou=employees +
+
Secondary User Relative DNs: + +
+ A list of Distinguished Name path(s) which forms the secondary base path(s) for + looking up all user LDAP records, relative to the Base DN defined above. These path(s) + will be tried if the user is not found via the primary relative DN. +
+
+ Example: [ou=employees] +
+
Administrator DN: +
+ The Distinguished Name for the Administrator account. This account must be able to login and view the records for all user accounts. +
+
+ Example: uid=admin,ou=employees,dc=my,dc=domain,dc=com +
+
Administrator DN Password: +
+ Note: This will be stored in + plaintext inside the config.yaml, so setting up a dedicated account or using + a password hash is highly recommended. +
+ +
+ The password for the Administrator DN. +
+
UID Attribute: + +
+ The name of the property field in your LDAP user records that stores your + users' username. Typically "uid". +
+
Mail Attribute: + +
+ The name of the property field in your LDAP user records that stores your + users' e-mail address(es). Typically "mail". +
+
Custom TLS Certificate: + +
+ If specified, the certificate (in PEM format) for the LDAP TLS connection. +
+
Allow insecure: +
+ Allow fallback to non-TLS connections +
+
+ If enabled, LDAP will fallback to insecure non-TLS connections if TLS does not succeed. +
+
+
+
+ +
+
+ External Authorization (OAuth) +
+
+ +
+
+ GitHub (Enterprise) Authentication +
+
+
+

+ If enabled, users can use GitHub or GitHub Enterprise to authenticate to the registry. +

+

+ Note: A registered GitHub (Enterprise) OAuth application is required. + View instructions on how to + + Create an OAuth Application in GitHub + +

+
+ +
+ Enable GitHub Authentication +
+ +
+ Warning: This provider is not bound to your {{ config.AUTHENTICATION_TYPE }} authentication. Logging in via this provider will create a -only user, which is not the recommended approach. It is highly recommended to choose a "Binding Field" below. +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
GitHub: + +
GitHub Endpoint: + + +
+ The GitHub Enterprise endpoint. Must start with http:// or https://. +
+
OAuth Client ID: + + +
OAuth Client Secret: + + +
Organization Filtering: +
+ Restrict By Organization Membership +
+ +
+ If enabled, only members of specified GitHub + Enterprise organizations will be allowed to login via GitHub + Enterprise. +
+ + + +
Binding Field: + +
+ If selected, when a user logs in via this provider, they will be automatically bound to their user in {{ config.AUTHENTICATION_TYPE }} by matching the selected field from the provider to the associated user in {{ config.AUTHENTICATION_TYPE }}. +
+
+ For example, selecting Subject here with a backing authentication system of LDAP means that a user logging in via this provider will also be bound to their user in LDAP by username. +
+
+ If none selected, a user unique to will be created on initial login with this provider. This is not the recommended setup. +
+
+
+
+ + +
+
+ Google Authentication +
+
+
+

+ If enabled, users can use Google to authenticate to the registry. +

+

+ Note: A registered Google OAuth application is required. + Visit the + + Google Developer Console + + to register an application. +

+
+ +
+ Enable Google Authentication +
+ +
+ Warning: This provider is not bound to your {{ config.AUTHENTICATION_TYPE }} authentication. Logging in via this provider will create a -only user, which is not the recommended approach. It is highly recommended to choose a "Binding Field" below. +
+ + + + + + + + + + + + + + +
OAuth Client ID: + + +
OAuth Client Secret: + + +
Binding Field: + +
+ If selected, when a user logs in via this provider, they will be automatically bound to their user in {{ config.AUTHENTICATION_TYPE }} by matching the selected field from the provider to the associated user in {{ config.AUTHENTICATION_TYPE }}. +
+
+ For example, selecting Subject here with a backing authentication system of LDAP means that a user logging in via this provider will also be bound to their user in LDAP by username. +
+
+ If none selected, a user unique to will be created on initial login with this provider. This is not the recommended setup. +
+
+
+
+ + +
+
+ + {{ config[provider]['SERVICE_NAME'] || (getOIDCProviderId(provider) + ' Authentication') }} + (Delete) +
+
+
+ Warning: This OIDC provider is not bound to your {{ config.AUTHENTICATION_TYPE }} authentication. Logging in via this provider will create a -only user, which is not the recommended approach. It is highly recommended to choose a "Binding Field" below. +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Service ID: + {{ getOIDCProviderId(provider) }} +
OIDC Server: + + +
+ The URL of an OIDC-compliant server. +
+
Client ID: + +
Client Secret: + +
Service Name: + + +
+ The user friendly name to display for the service on the login page. +
+
Service Icon (optional): + + +
+ If specified, the icon to display for this login service on the login page. Can be either a URL to an icon or a CSS class name from Font Awesome +
+
Binding Field: + +
+ If selected, when a user logs in via this OIDC provider, they will be automatically bound to their user in {{ config.AUTHENTICATION_TYPE }} by matching the selected field from the OIDC provider to the associated user in {{ config.AUTHENTICATION_TYPE }}. +
+
+ For example, selecting Subject here with a backing authentication system of LDAP means that a user logging in via this OIDC provider will also be bound to their user in LDAP by username. +
+
+ If none selected, a user unique to will be created on initial login with this OIDC provider. This is not the recommended setup. +
+
Login Scopes: + +
+ If specified, the scopes to send to the OIDC provider when performing the login flow. Note that, if specified, these scopes will + override those set by default, so this list must include a scope for OpenID Connect + (typically the openid scope) or this provider will fail. +
+
+
+

Callback URLs for this service:

+
    +
  • {{ mapped.TLS_SETTING == 'none' ? 'http' : 'https' }}://{{ config.SERVER_HOSTNAME || '(configure server hostname)' }}/oauth2/{{ getOIDCProviderId(provider).toLowerCase() }}/callback
  • +
  • {{ mapped.TLS_SETTING == 'none' ? 'http' : 'https' }}://{{ config.SERVER_HOSTNAME || '(configure server hostname)' }}/oauth2/{{ getOIDCProviderId(provider).toLowerCase() }}/callback/attach
  • +
  • {{ mapped.TLS_SETTING == 'none' ? 'http' : 'https' }}://{{ config.SERVER_HOSTNAME || '(configure server hostname)' }}/oauth2/{{ getOIDCProviderId(provider).toLowerCase() }}/callback/cli
  • +
+
+
+
+ + + Add OIDC Provider + What is OIDC? +
+
+ + +
+
+ Access Settings +
+
+
+

Various settings around access and authentication to the registry.

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Basic Credentials Login: +
+ Login to User Interface via credentials +
+
+
+ Login to User Interface via credentials must be enabled. Click here to enable. +
+
+ Login to User Interface via credentials is enabled (requires at least one OIDC provider to disable) +
+
+
+ If enabled, users will be able to login to the user interface via their username and password credentials. +
+
+ If disabled, users will only be able to login to the user interface via one of the configured External Authentication providers. +
+
External Application tokens +
+ Allow external application tokens +
+
+ If enabled, users will be able to generate external application tokens for use on the Docker and rkt CLI. Note + that these tokens will not be required unless "App Token" is chosen as the Internal Authentication method above. +
+
External application token expiration + +
+ The expiration time for user generated external application tokens. If none, tokens will never expire. +
+
Anonymous Access: +
+ Enable Anonymous Access +
+
+ If enabled, public repositories and search can be accessed by anyone that can + reach the registry, even if they are not authenticated. Disable to only allow + authenticated users to view and pull "public" resources. +
+
User Creation: +
+ Enable Open User Creation +
+
+ If enabled, user accounts can be created by anyone (unless restricted below to invited users). + Users can always be created in the users panel in this superuser tool, even if this feature is disabled. +
+
Invite-only User Creation: +
+ Enable Invite-only User Creation +
+
+ If enabled, user accounts can only be created when a user has been invited, by e-mail address, to join a team. + Users can always be created in the users panel in this superuser tool, even if this feature is enabled. +
+
Encrypted Client Password: +
+ Require Encrypted Client Passwords +
+
+ If enabled, users will not be able to login from the Docker command + line with a non-encrypted password and must generate an encrypted + password to use. +
+
+ This feature is highly recommended for setups with external authentication, as Docker currently stores passwords in plaintext on user's machines. +
+
Prefix username autocompletion: +
+ Allow prefix username autocompletion +
+
+ If disabled, autocompletion for users will only match on exact usernames. +
+
Team Invitations: +
+ Require Team Invitations +
+
+ If enabled, when adding a new user to a team, they will receive an invitation to join the team, with the option to decline. + Otherwise, users will be immediately part of a team when added by a team administrator. +
+
+
+
+ + +
+
+ Dockerfile Build Support +
+
+
+ If enabled, users can submit Dockerfiles to be built and pushed by . +
+ +
+ Enable Dockerfile Build +
+ +
+ Note: Build workers are required for this feature. + See Adding Build Workers for instructions on how to setup build workers. +
+
+
+ + +
+
+ GitHub (Enterprise) Build Triggers +
+
+
+

+ If enabled, users can setup GitHub or GitHub Enterprise triggers to invoke Registry builds. +

+

+ Note: A registered GitHub (Enterprise) OAuth application (separate from GitHub Authentication) is required. + View instructions on how to + + Create an OAuth Application in GitHub + +

+
+ +
+ Enable GitHub Triggers +
+ + + + + + + + + + + + + + + + + + +
GitHub: + +
GitHub Endpoint: + + +
+ The GitHub Enterprise endpoint. Must start with http:// or https://. +
+
OAuth Client ID: + + +
OAuth Client Secret: + + +
+
+
+ + +
+
+ BitBucket Build Triggers +
+
+
+

+ If enabled, users can setup BitBucket triggers to invoke Registry builds. +

+

+ Note: A registered BitBucket OAuth application is required. + View instructions on how to + + Create an OAuth Application in BitBucket + +

+
+ +
+ Enable BitBucket Triggers +
+ + + + + + + + + + +
OAuth Consumer Key: + + +
OAuth Consumer Secret: + + +
+
+
+ + +
+
+ GitLab Build Triggers +
+
+
+

+ If enabled, users can setup GitLab triggers to invoke Registry builds. +

+

+ Note: A registered GitLab OAuth application is required. + Visit the + + GitLab applications admin panel + + to create a new application. +

+

The callback URL to use is:   + {{ config.PREFERRED_URL_SCHEME || 'http' }}://{{ config.SERVER_HOSTNAME || 'localhost' }}/oauth2/gitlab/callback/trigger +

+
+ +
+ Enable GitLab Triggers +
+ + + + + + + + + + + + + + + + + + +
GitLab: + +
GitLab Endpoint: + + +
+ The GitLab Enterprise endpoint. Must start with http:// or https://. +
+
Application Id: + + +
Secret: + + +
+
+
+ + + + +
+ + +
+ + + + +
+
diff --git a/config_app/js/config-field-templates/config-string-field.html b/config_app/js/config-field-templates/config-string-field.html new file mode 100644 index 000000000..703891f89 --- /dev/null +++ b/config_app/js/config-field-templates/config-string-field.html @@ -0,0 +1,10 @@ +
+
+ +
+ {{ errorMessage }} +
+
+
diff --git a/config_app/js/config-field-templates/config-string-list-field.html b/config_app/js/config-field-templates/config-string-list-field.html new file mode 100644 index 000000000..de29dfb91 --- /dev/null +++ b/config_app/js/config-field-templates/config-string-list-field.html @@ -0,0 +1,6 @@ +
+
+ +
+
diff --git a/config_app/js/config-field-templates/config-variable-field.html b/config_app/js/config-field-templates/config-variable-field.html new file mode 100644 index 000000000..9236469cd --- /dev/null +++ b/config_app/js/config-field-templates/config-variable-field.html @@ -0,0 +1,10 @@ +
+
+ +
+ + +
diff --git a/config_app/js/core-config-setup/config-setup-tool.html b/config_app/js/core-config-setup/config-setup-tool.html new file mode 100644 index 000000000..ec3faa1c7 --- /dev/null +++ b/config_app/js/core-config-setup/config-setup-tool.html @@ -0,0 +1,1657 @@ +
+
+
+ +
+ + +
+
+ Custom SSL Certificates +
+
+
+
+
+ + +
+
+ Basic Configuration +
+
+ + + + + + + + + + +
Enterprise Logo URL: + +
+ Enter the full URL to your company's logo. +
+
+ +
Contact Information: + +
+ Information to show in the Contact Page. If none specified, CoreOS contact information + is displayed. +
+
+
+
+ + +
+
+ Server Configuration +
+
+ + + + + + + + + +
Server Hostname: + +
+ The HTTP host (and optionally the port number if a non-standard HTTP/HTTPS port) of the location + where the registry will be accessible on the network +
+
TLS: + + +
+ Running without TLS should not be used for production workloads! +
+ +
+ Terminating TLS outside of Quay Enterprise can result in unusual behavior if the external load balancer is not + configured properly. This option is not recommended for simple setups. Please contact support + if you encounter problems while using this option. +
+ +
+ Enabling TLS also enables HTTP Strict Transport Security.
+ This prevents downgrade attacks and cookie theft, but browsers will reject all future insecure connections on this hostname. +
+ + + + + + + + + + +
Certificate: + +
+ The certificate must be in PEM format. +
+
Private key: + +
+
+ +
+
+ + +
+
+ Data Consistency Settings +
+
+
+

Relax constraints on consistency guarantees for specific operations + to enable higher performance and availability. +

+
+ + + + +
+
+ Allow repository pulls even if audit logging fails. +
+ If enabled, failures to write to the audit log will fallback from + the database to the standard logger for registry pulls. +
+
+
+
+
+ + +
+
+ Time Machine +
+
+
+

Time machine keeps older copies of tags within a repository for the configured period + of time, after which they are garbage collected. This allows users to + revert tags to older images in case they accidentally pushed a broken image. It is + highly recommended to have time machine enabled, but it does take a bit more space + in storage. +

+
+ + + + + + + + + + + + + + +
Allowed expiration periods: + +
+ The expiration periods allowed for configuration. The default tag expiration *must* be in this list. +
+
Default expiration period: + +
+ The default tag expiration period for all namespaces (users and organizations). Must be expressed in a duration string form: 30m, 1h, 1d, 2w. +
+
Allow users to select expiration: +
+ Enable Expiration Configuration +
+ If enabled, users will be able to select the tag expiration duration for the namespace(s) they + administrate, from the configured list of options. +
+
+
+
+
+ + +
+
+ redis +
+
+
+

A redis key-value store is required for real-time events and build logs.

+
+ + + + + + + + + + + + + + +
Redis Hostname: + +
Redis port: + +
+ Access to this port and hostname must be allowed from all hosts running + the enterprise registry +
+
Redis password: + +
+
+
+ + +
+
+ Registry Storage +
+
+
+

+ Registry images can be stored either locally or in a remote storage system. + A remote storage system is required for high-availability systems. +

+ +
+ Enable Storage Replication +
+ If enabled, replicates storage to other regions. See documentation for more information. +
+
+ +
+ + + + + + + + + + + + + + + + + + + + + +
Location ID: + +
+ {{ sc.location }} +
+
+ {{ storageConfigError[$index].location }} +
+ +
Set Default: +
+ Replicate to storage engine by default +
+
Storage Engine: + + +
+ {{ storageConfigError[$index].engine }} +
+
{{ field.title }}: + + + + {{ field.placeholder }} + + +
+ +
+
+ {{ field.help_text }} +
+
+ See Documentation for more information +
+
+
+ + +
+
+
+ + +
+
+ Action Log Rotation and Archiving +
+
+
+

+ All actions performed in are automatically logged. These logs are stored in a database table, which can become quite large. + Enabling log rotation and archiving will move all logs older than 30 days into storage. +

+
+
+ Enable Action Log Rotation +
+ + + + + + + + + + +
Storage location: + +
+ The storage location in which to place archived action logs. Logs will only be archived to this single location. +
+
Storage path: + +
+ The path under the configured storage engine in which to place the archived logs in JSON form. +
+
+
+ + +
+
+ Security Scanner +
+
+
+

If enabled, all images pushed to Quay will be scanned via the external security scanning service, with vulnerability information available in the UI and API, as well + as async notification support. +

+
+ +
+ Enable Security Scanning +
+
+ A scanner compliant with the Quay Security Scanning API must be running to use this feature. Documentation on running Clair can be found at Running Clair Security Scanner. +
+ + + + + + + + + + +
Authentication Key: + +
+ The security scanning service requires an authorized service key to speak to Quay. Once setup, the key + can be managed in the Service Keys panel under the Super User Admin Panel. +
+
Security Scanner Endpoint: + +
+ The HTTP URL at which the security scanner is running. +
+
+ Is the security scanner behind a domain signed with a self-signed TLS certificate? If so, please make sure to register your SSL CA in the custom certificates panel above. +
+
+
+
+ + +
+
+ Application Registry +
+
+
+

If enabled, an additional registry API will be available for managing applications (Kubernetes manifests, Helm charts) via the App Registry specification. A great place to get started is to install the Helm Registry Plugin. +

+ +
+ Enable App Registry +
+
+
+ + +
+
+ BitTorrent-based download +
+
+
+

If enabled, all images in the registry can be downloaded using the quayctl tool via the BitTorrent protocol. A JWT-compatible BitTorrent tracker such as Chihaya must be run. +

+ +
+ Enable BitTorrent downloads +
+ + + + + + +
Announce URL: + +
+ The HTTP URL at which the torrents should be announced. A JWT-compatible tracker such as Chihaya must be run to ensure proper security. Documentation on running Chihaya with + this support can be found at Running Chihaya for Quay Enterprise. +
+
+
+
+ + +
+
+ rkt Conversion +
+
+
+

If enabled, all images in the registry can be fetched via rkt fetch or any other AppC discovery-compliant implementation.

+
+ +
+ Enable ACI Conversion +
+ +
+ Documentation on generating these keys can be found at Generating ACI Signing Keys. +
+ + + + + + + + + + + + + + +
GPG2 Public Key File: + +
+ The certificate must be in PEM format. +
+
GPG2 Private Key File: + +
GPG2 Private Key Name: + +
+
+
+ + +
+
+ E-mail +
+
+
+

Valid e-mail server configuration is required for notification e-mails and the ability of + users to reset their passwords.

+
+ +
+ Enable E-mails +
+ + + + + + + + + + + + + + + + + + + + + + + +
SMTP Server: + > +
SMTP Server Port: + +
TLS: +
+ Require TLS +
+
Mail Sender: + +
+ E-mail address from which all e-mails are sent. If not specified, + support@quay.io will be used. +
+
Authentication: +
+ Requires Authentication +
+ + + + + + + + + + +
Username: + +
Password: + +
+
+
+
+ + +
+
+ Internal Authentication +
+
+
+

+ Authentication for the registry can be handled by either the registry itself, LDAP, Keystone, or external JWT endpoint. +

+

+ Additional external authentication providers (such as GitHub) can be used in addition for login into the UI. +

+
+ +
+
+ It is highly recommended to require encrypted client passwords. External passwords used in the Docker client will be stored in plaintext! + Enable this requirement now. +
+ +
+ Note: The "Require Encrypted Client Passwords" feature is currently enabled which will + prevent passwords from being saved as plaintext by the Docker client. +
+
+ + + + + + + + + + + + + + + + + + + +
Authentication: + +
Team synchronization: +
+ Enable Team Synchronization Support +
+
+ If enabled, organization administrators who are also superusers can set teams to have their membership synchronized with a backing group in {{ config.AUTHENTICATION_TYPE }}. +
+
Resynchronization duration: + +
+ The duration before a team must be re-synchronized. Must be expressed in a duration string form: 30m, 1h, 1d. +
+
Self-service team syncing setup: +
If enabled, this feature will allow *any organization administrator* to read the membership of any {{ config.AUTHENTICATION_TYPE }} group.
+
+ Allow non-superusers to enable and manage team syncing +
+
+ If enabled, non-superusers will be able to enable and manage team sycning on teams under organizations in which they are administrators. +
+
+ + + + + + + + + + + + + + + + + + + + + + + +
Keystone API Version: + +
Keystone Authentication URL: + +
+ The URL (starting with http or https) of the Keystone Server endpoint for auth. +
+
Keystone Administrator Username: + +
+ The username for the Keystone admin. +
+
Keystone Administrator Password: + +
+ The password for the Keystone admin. +
+
Keystone Administrator Tenant: + +
+ The tenant (project/group) that contains the administrator user. +
+
+ + +
+ JSON Web Token authentication allows your organization to provide an HTTP endpoint that + verifies user credentials on behalf of . +
+ Documentation + on the API required can be found here: https://github.com/coreos/jwt-auth-example. +
+ + + + + + + + + + + + + + + + + + + + + + +
Authentication Issuer: + +
+ The id of the issuer signing the JWT token. Must be unique to your organization. +
+
Public Key: + +
+ A certificate containing the public key portion of the key pair used to sign + the JSON Web Tokens. This file must be in PEM format. +
+
User Verification Endpoint: + +
+ The URL (starting with http or https) on the JWT authentication server for verifying username and password credentials. +
+ +
+ Credentials will be sent in the Authorization header as Basic Auth, and this endpoint should return 200 OK on success (or a 4** otherwise). +
+
User Query Endpoint: + +
+ The URL (starting with http or https) on the JWT authentication server for looking up + users based on a prefix query. This is optional. +
+ +
+ The prefix query will be sent as a query parameter with name query. +
+
User Lookup Endpoint: + +
+ The URL (starting with http or https) on the JWT authentication server for looking up + a user by username or email address. +
+ +
+ The username or email address will be sent as a query parameter with name username. +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
LDAP URI: + +
+ The full LDAP URI, including the ldap:// or ldaps:// prefix. +
+
Base DN: + +
+ A Distinguished Name path which forms the base path for looking up all LDAP records. +
+
+ Example: dc=my,dc=domain,dc=com +
+
User Relative DN: + +
+ A Distinguished Name path which forms the base path for looking up all user LDAP records, + relative to the Base DN defined above. +
+
+ Example: ou=employees +
+
Secondary User Relative DNs: + +
+ A list of Distinguished Name path(s) which forms the secondary base path(s) for + looking up all user LDAP records, relative to the Base DN defined above. These path(s) + will be tried if the user is not found via the primary relative DN. +
+
+ Example: [ou=employees] +
+
Administrator DN: +
+ The Distinguished Name for the Administrator account. This account must be able to login and view the records for all user accounts. +
+
+ Example: uid=admin,ou=employees,dc=my,dc=domain,dc=com +
+
Administrator DN Password: +
+ Note: This will be stored in + plaintext inside the config.yaml, so setting up a dedicated account or using + a password hash is highly recommended. +
+ +
+ The password for the Administrator DN. +
+
UID Attribute: + +
+ The name of the property field in your LDAP user records that stores your + users' username. Typically "uid". +
+
Mail Attribute: + +
+ The name of the property field in your LDAP user records that stores your + users' e-mail address(es). Typically "mail". +
+
Custom TLS Certificate: + +
+ If specified, the certificate (in PEM format) for the LDAP TLS connection. +
+
Allow insecure: +
+ Allow fallback to non-TLS connections +
+
+ If enabled, LDAP will fallback to insecure non-TLS connections if TLS does not succeed. +
+
+
+
+ +
+
+ External Authorization (OAuth) +
+
+ +
+
+ GitHub (Enterprise) Authentication +
+
+
+

+ If enabled, users can use GitHub or GitHub Enterprise to authenticate to the registry. +

+

+ Note: A registered GitHub (Enterprise) OAuth application is required. + View instructions on how to + + Create an OAuth Application in GitHub + +

+
+ +
+ Enable GitHub Authentication +
+ +
+ Warning: This provider is not bound to your {{ config.AUTHENTICATION_TYPE }} authentication. Logging in via this provider will create a -only user, which is not the recommended approach. It is highly recommended to choose a "Binding Field" below. +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
GitHub: + +
GitHub Endpoint: + + +
+ The GitHub Enterprise endpoint. Must start with http:// or https://. +
+
OAuth Client ID: + + +
OAuth Client Secret: + + +
Organization Filtering: +
+ Restrict By Organization Membership +
+ +
+ If enabled, only members of specified GitHub + Enterprise organizations will be allowed to login via GitHub + Enterprise. +
+ + + +
Binding Field: + +
+ If selected, when a user logs in via this provider, they will be automatically bound to their user in {{ config.AUTHENTICATION_TYPE }} by matching the selected field from the provider to the associated user in {{ config.AUTHENTICATION_TYPE }}. +
+
+ For example, selecting Subject here with a backing authentication system of LDAP means that a user logging in via this provider will also be bound to their user in LDAP by username. +
+
+ If none selected, a user unique to will be created on initial login with this provider. This is not the recommended setup. +
+
+
+
+ + +
+
+ Google Authentication +
+
+
+

+ If enabled, users can use Google to authenticate to the registry. +

+

+ Note: A registered Google OAuth application is required. + Visit the + + Google Developer Console + + to register an application. +

+
+ +
+ Enable Google Authentication +
+ +
+ Warning: This provider is not bound to your {{ config.AUTHENTICATION_TYPE }} authentication. Logging in via this provider will create a -only user, which is not the recommended approach. It is highly recommended to choose a "Binding Field" below. +
+ + + + + + + + + + + + + + +
OAuth Client ID: + + +
OAuth Client Secret: + + +
Binding Field: + +
+ If selected, when a user logs in via this provider, they will be automatically bound to their user in {{ config.AUTHENTICATION_TYPE }} by matching the selected field from the provider to the associated user in {{ config.AUTHENTICATION_TYPE }}. +
+
+ For example, selecting Subject here with a backing authentication system of LDAP means that a user logging in via this provider will also be bound to their user in LDAP by username. +
+
+ If none selected, a user unique to will be created on initial login with this provider. This is not the recommended setup. +
+
+
+
+ + +
+
+ + {{ config[provider]['SERVICE_NAME'] || (getOIDCProviderId(provider) + ' Authentication') }} + (Delete) +
+
+
+ Warning: This OIDC provider is not bound to your {{ config.AUTHENTICATION_TYPE }} authentication. Logging in via this provider will create a -only user, which is not the recommended approach. It is highly recommended to choose a "Binding Field" below. +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Service ID: + {{ getOIDCProviderId(provider) }} +
OIDC Server: + + +
+ The URL of an OIDC-compliant server. +
+
Client ID: + +
Client Secret: + +
Service Name: + + +
+ The user friendly name to display for the service on the login page. +
+
Service Icon (optional): + + +
+ If specified, the icon to display for this login service on the login page. Can be either a URL to an icon or a CSS class name from Font Awesome +
+
Binding Field: + +
+ If selected, when a user logs in via this OIDC provider, they will be automatically bound to their user in {{ config.AUTHENTICATION_TYPE }} by matching the selected field from the OIDC provider to the associated user in {{ config.AUTHENTICATION_TYPE }}. +
+
+ For example, selecting Subject here with a backing authentication system of LDAP means that a user logging in via this OIDC provider will also be bound to their user in LDAP by username. +
+
+ If none selected, a user unique to will be created on initial login with this OIDC provider. This is not the recommended setup. +
+
Login Scopes: + +
+ If specified, the scopes to send to the OIDC provider when performing the login flow. Note that, if specified, these scopes will + override those set by default, so this list must include a scope for OpenID Connect + (typically the openid scope) or this provider will fail. +
+
+
+

Callback URLs for this service:

+
    +
  • {{ mapped.TLS_SETTING == 'none' ? 'http' : 'https' }}://{{ config.SERVER_HOSTNAME || '(configure server hostname)' }}/oauth2/{{ getOIDCProviderId(provider).toLowerCase() }}/callback
  • +
  • {{ mapped.TLS_SETTING == 'none' ? 'http' : 'https' }}://{{ config.SERVER_HOSTNAME || '(configure server hostname)' }}/oauth2/{{ getOIDCProviderId(provider).toLowerCase() }}/callback/attach
  • +
  • {{ mapped.TLS_SETTING == 'none' ? 'http' : 'https' }}://{{ config.SERVER_HOSTNAME || '(configure server hostname)' }}/oauth2/{{ getOIDCProviderId(provider).toLowerCase() }}/callback/cli
  • +
+
+
+
+ + + Add OIDC Provider + What is OIDC? +
+
+ + +
+
+ Access Settings +
+
+
+

Various settings around access and authentication to the registry.

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Basic Credentials Login: +
+ Login to User Interface via credentials +
+
+
+ Login to User Interface via credentials must be enabled. Click here to enable. +
+
+ Login to User Interface via credentials is enabled (requires at least one OIDC provider to disable) +
+
+
+ If enabled, users will be able to login to the user interface via their username and password credentials. +
+
+ If disabled, users will only be able to login to the user interface via one of the configured External Authentication providers. +
+
External Application tokens +
+ Allow external application tokens +
+
+ If enabled, users will be able to generate external application tokens for use on the Docker and rkt CLI. Note + that these tokens will not be required unless "App Token" is chosen as the Internal Authentication method above. +
+
External application token expiration + +
+ The expiration time for user generated external application tokens. If none, tokens will never expire. +
+
Anonymous Access: +
+ Enable Anonymous Access +
+
+ If enabled, public repositories and search can be accessed by anyone that can + reach the registry, even if they are not authenticated. Disable to only allow + authenticated users to view and pull "public" resources. +
+
User Creation: +
+ Enable Open User Creation +
+
+ If enabled, user accounts can be created by anyone (unless restricted below to invited users). + Users can always be created in the users panel in this superuser tool, even if this feature is disabled. +
+
Invite-only User Creation: +
+ Enable Invite-only User Creation +
+
+ If enabled, user accounts can only be created when a user has been invited, by e-mail address, to join a team. + Users can always be created in the users panel in this superuser tool, even if this feature is enabled. +
+
Encrypted Client Password: +
+ Require Encrypted Client Passwords +
+
+ If enabled, users will not be able to login from the Docker command + line with a non-encrypted password and must generate an encrypted + password to use. +
+
+ This feature is highly recommended for setups with external authentication, as Docker currently stores passwords in plaintext on user's machines. +
+
Prefix username autocompletion: +
+ Allow prefix username autocompletion +
+
+ If disabled, autocompletion for users will only match on exact usernames. +
+
Team Invitations: +
+ Require Team Invitations +
+
+ If enabled, when adding a new user to a team, they will receive an invitation to join the team, with the option to decline. + Otherwise, users will be immediately part of a team when added by a team administrator. +
+
+
+
+ + +
+
+ Dockerfile Build Support +
+
+
+ If enabled, users can submit Dockerfiles to be built and pushed by . +
+ +
+ Enable Dockerfile Build +
+ +
+ Note: Build workers are required for this feature. + See Adding Build Workers for instructions on how to setup build workers. +
+
+
+ + +
+
+ GitHub (Enterprise) Build Triggers +
+
+
+

+ If enabled, users can setup GitHub or GitHub Enterprise triggers to invoke Registry builds. +

+

+ Note: A registered GitHub (Enterprise) OAuth application (separate from GitHub Authentication) is required. + View instructions on how to + + Create an OAuth Application in GitHub + +

+
+ +
+ Enable GitHub Triggers +
+ + + + + + + + + + + + + + + + + + +
GitHub: + +
GitHub Endpoint: + + +
+ The GitHub Enterprise endpoint. Must start with http:// or https://. +
+
OAuth Client ID: + + +
OAuth Client Secret: + + +
+
+
+ + +
+
+ BitBucket Build Triggers +
+
+
+

+ If enabled, users can setup BitBucket triggers to invoke Registry builds. +

+

+ Note: A registered BitBucket OAuth application is required. + View instructions on how to + + Create an OAuth Application in BitBucket + +

+
+ +
+ Enable BitBucket Triggers +
+ + + + + + + + + + +
OAuth Consumer Key: + + +
OAuth Consumer Secret: + + +
+
+
+ + +
+
+ GitLab Build Triggers +
+
+
+

+ If enabled, users can setup GitLab triggers to invoke Registry builds. +

+

+ Note: A registered GitLab OAuth application is required. + Visit the + + GitLab applications admin panel + + to create a new application. +

+

The callback URL to use is:   + {{ config.PREFERRED_URL_SCHEME || 'http' }}://{{ config.SERVER_HOSTNAME || 'localhost' }}/oauth2/gitlab/callback/trigger +

+
+ +
+ Enable GitLab Triggers +
+ + + + + + + + + + + + + + + + + + +
GitLab: + +
GitLab Endpoint: + + +
+ The GitLab Enterprise endpoint. Must start with http:// or https://. +
+
Application Id: + + +
Secret: + + +
+
+
+ + + + +
+ + +
+ + + + +
+
diff --git a/config_app/js/core-config-setup/core-config-setup.js b/config_app/js/core-config-setup/core-config-setup.js new file mode 100644 index 000000000..d069ab186 --- /dev/null +++ b/config_app/js/core-config-setup/core-config-setup.js @@ -0,0 +1,1454 @@ +import * as URI from 'urijs'; +import * as angular from 'angular'; +const templateUrl = require('./config-setup-tool.html'); +const urlParsedField = require('../config-field-templates/config-parsed-field.html'); +const urlVarField = require('../config-field-templates/config-variable-field.html'); +const urlListField = require('../config-field-templates/config-list-field.html'); +const urlFileField = require('../config-field-templates/config-file-field.html'); +const urlBoolField = require('../config-field-templates/config-bool-field.html'); +const urlNumericField = require('../config-field-templates/config-numeric-field.html'); +const urlContactsField = require('../config-field-templates/config-contacts-field.html'); +const urlMapField = require('../config-field-templates/config-map-field.html'); +const urlServiceKeyField = require('../config-field-templates/config-service-key-field.html'); +const urlStringField = require('../config-field-templates/config-string-field.html'); + +const urlStringListField = require('../config-field-templates/config-string-list-field.html'); +const urlCertField = require('../config-field-templates/config-certificates-field.html'); + + +angular.module("quay-config") + .directive('configSetupTool', () => { + var directiveDefinitionObject = { + priority: 1, + templateUrl, + replace: true, + transclude: true, + restrict: 'C', + scope: { + 'isActive': '=isActive', + 'configurationSaved': '&configurationSaved' + }, + controller: function($rootScope, $scope, $element, $timeout, ApiService) { + console.log('in the controller of the configSetupTool') + + var authPassword = null; + + $scope.HOSTNAME_REGEX = '^[a-zA-Z-0-9\.]+(:[0-9]+)?$'; + $scope.GITHOST_REGEX = '^https?://([a-zA-Z0-9]+\.?\/?)+$'; + + $scope.SERVICES = [ + {'id': 'redis', 'title': 'Redis'}, + + {'id': 'registry-storage', 'title': 'Registry Storage'}, + + {'id': 'time-machine', 'title': 'Time Machine'}, + + {'id': 'access', 'title': 'Access Settings'}, + + {'id': 'ssl', 'title': 'SSL certificate and key', 'condition': function(config) { + return config.PREFERRED_URL_SCHEME == 'https'; + }}, + + {'id': 'ldap', 'title': 'LDAP Authentication', 'condition': function(config) { + return config.AUTHENTICATION_TYPE == 'LDAP'; + }, 'password': true}, + + {'id': 'jwt', 'title': 'JWT Authentication', 'condition': function(config) { + return config.AUTHENTICATION_TYPE == 'JWT'; + }, 'password': true}, + + {'id': 'keystone', 'title': 'Keystone Authentication', 'condition': function(config) { + return config.AUTHENTICATION_TYPE == 'Keystone'; + }, 'password': true}, + + {'id': 'apptoken-auth', 'title': 'App Token Authentication', 'condition': function(config) { + return config.AUTHENTICATION_TYPE == 'AppToken'; + }}, + + {'id': 'signer', 'title': 'ACI Signing', 'condition': function(config) { + return config.FEATURE_ACI_CONVERSION; + }}, + + {'id': 'mail', 'title': 'E-mail Support', 'condition': function(config) { + return config.FEATURE_MAILING; + }}, + + {'id': 'github-login', 'title': 'Github (Enterprise) Authentication', 'condition': function(config) { + return config.FEATURE_GITHUB_LOGIN; + }}, + + {'id': 'google-login', 'title': 'Google Authentication', 'condition': function(config) { + return config.FEATURE_GOOGLE_LOGIN; + }}, + + {'id': 'github-trigger', 'title': 'GitHub (Enterprise) Build Triggers', 'condition': function(config) { + return config.FEATURE_GITHUB_BUILD; + }}, + + {'id': 'bitbucket-trigger', 'title': 'BitBucket Build Triggers', 'condition': function(config) { + return config.FEATURE_BITBUCKET_BUILD; + }}, + + {'id': 'gitlab-trigger', 'title': 'GitLab Build Triggers', 'condition': function(config) { + return config.FEATURE_GITLAB_BUILD; + }}, + + {'id': 'security-scanner', 'title': 'Quay Security Scanner', 'condition': function(config) { + return config.FEATURE_SECURITY_SCANNER; + }}, + + {'id': 'bittorrent', 'title': 'BitTorrent downloads', 'condition': function(config) { + return config.FEATURE_BITTORRENT; + }}, + + {'id': 'oidc-login', 'title': 'OIDC Login(s)', 'condition': function(config) { + return $scope.getOIDCProviders(config).length > 0; + }}, + + {'id': 'actionlogarchiving', 'title': 'Action Log Rotation', 'condition': function(config) { + return config.FEATURE_ACTION_LOG_ROTATION; + }}, + ]; + + $scope.STORAGE_CONFIG_FIELDS = { + 'LocalStorage': [ + {'name': 'storage_path', 'title': 'Storage Directory', 'placeholder': '/some/directory', 'kind': 'text'} + ], + + 'S3Storage': [ + {'name': 's3_bucket', 'title': 'S3 Bucket', 'placeholder': 'my-cool-bucket', 'kind': 'text'}, + {'name': 'storage_path', 'title': 'Storage Directory', 'placeholder': '/path/inside/bucket', 'kind': 'text'}, + {'name': 's3_access_key', 'title': 'AWS Access Key (optional if using IAM)', 'placeholder': 'accesskeyhere', 'kind': 'text', 'optional': true}, + {'name': 's3_secret_key', 'title': 'AWS Secret Key (optional if using IAM)', 'placeholder': 'secretkeyhere', 'kind': 'text', 'optional': true}, + {'name': 'host', 'title': 'S3 Host (optional)', 'placeholder': 's3.amazonaws.com', 'kind': 'text', 'optional': true}, + {'name': 'port', 'title': 'S3 Port (optional)', 'placeholder': '443', 'kind': 'text', 'pattern': '^[0-9]+$', 'optional': true} + ], + + 'AzureStorage': [ + {'name': 'azure_container', 'title': 'Azure Storage Container', 'placeholder': 'container', 'kind': 'text'}, + {'name': 'storage_path', 'title': 'Storage Directory', 'placeholder': '/path/inside/container', 'kind': 'text'}, + {'name': 'azure_account_name', 'title': 'Azure Account Name', 'placeholder': 'accountnamehere', 'kind': 'text'}, + {'name': 'azure_account_key', 'title': 'Azure Account Key', 'placeholder': 'accountkeyhere', 'kind': 'text', 'optional': true}, + {'name': 'sas_token', 'title': 'Azure SAS Token', 'placeholder': 'sastokenhere', 'kind': 'text', 'optional': true}, + ], + + 'GoogleCloudStorage': [ + {'name': 'access_key', 'title': 'Cloud Access Key', 'placeholder': 'accesskeyhere', 'kind': 'text'}, + {'name': 'secret_key', 'title': 'Cloud Secret Key', 'placeholder': 'secretkeyhere', 'kind': 'text'}, + {'name': 'bucket_name', 'title': 'GCS Bucket', 'placeholder': 'my-cool-bucket', 'kind': 'text'}, + {'name': 'storage_path', 'title': 'Storage Directory', 'placeholder': '/path/inside/bucket', 'kind': 'text'} + ], + + 'RadosGWStorage': [ + {'name': 'hostname', 'title': 'Rados Server Hostname', 'placeholder': 'my.rados.hostname', 'kind': 'text'}, + {'name': 'port', 'title': 'Custom Port (optional)', 'placeholder': '443', 'kind': 'text', 'pattern': '^[0-9]+$', 'optional': true}, + {'name': 'is_secure', 'title': 'Is Secure', 'placeholder': 'Require SSL', 'kind': 'bool'}, + {'name': 'access_key', 'title': 'Access Key', 'placeholder': 'accesskeyhere', 'kind': 'text', 'help_url': 'http://ceph.com/docs/master/radosgw/admin/'}, + {'name': 'secret_key', 'title': 'Secret Key', 'placeholder': 'secretkeyhere', 'kind': 'text'}, + {'name': 'bucket_name', 'title': 'Bucket Name', 'placeholder': 'my-cool-bucket', 'kind': 'text'}, + {'name': 'storage_path', 'title': 'Storage Directory', 'placeholder': '/path/inside/bucket', 'kind': 'text'} + ], + + 'SwiftStorage': [ + {'name': 'auth_version', 'title': 'Swift Auth Version', 'kind': 'option', 'values': [1, 2, 3]}, + {'name': 'auth_url', 'title': 'Swift Auth URL', 'placeholder': 'http://swiftdomain/auth/v1.0', 'kind': 'text'}, + {'name': 'swift_container', 'title': 'Swift Container Name', 'placeholder': 'mycontainer', 'kind': 'text', + 'help_text': 'The swift container for all objects. Must already exist inside Swift.'}, + + {'name': 'storage_path', 'title': 'Storage Path', 'placeholder': '/path/inside/container', 'kind': 'text'}, + + {'name': 'swift_user', 'title': 'Username', 'placeholder': 'accesskeyhere', 'kind': 'text', + 'help_text': 'Note: For Swift V1, this is "username:password" (-U on the CLI).'}, + {'name': 'swift_password', 'title': 'Key/Password', 'placeholder': 'secretkeyhere', 'kind': 'text', + 'help_text': 'Note: For Swift V1, this is the API token (-K on the CLI).'}, + + {'name': 'ca_cert_path', 'title': 'CA Cert Filename', 'placeholder': 'conf/stack/swift.cert', 'kind': 'text', 'optional': true}, + + {'name': 'temp_url_key', 'title': 'Temp URL Key (optional)', 'placholder': 'key-here', 'kind': 'text', 'optional': true, + 'help_url': 'https://coreos.com/products/enterprise-registry/docs/latest/swift-temp-url.html', + 'help_text': 'If enabled, will allow for faster pulls directly from Swift.'}, + + {'name': 'os_options', 'title': 'OS Options', 'kind': 'map', + 'keys': ['tenant_id', 'auth_token', 'service_type', 'endpoint_type', 'tenant_name', 'object_storage_url', 'region_name', + 'project_id', 'project_name', 'project_domain_name', 'user_domain_name', 'user_domain_id']} + ], + + 'CloudFrontedS3Storage': [ + {'name': 's3_bucket', 'title': 'S3 Bucket', 'placeholder': 'my-cool-bucket', 'kind': 'text'}, + {'name': 'storage_path', 'title': 'Storage Directory', 'placeholder': '/path/inside/bucket', 'kind': 'text'}, + {'name': 's3_access_key', 'title': 'AWS Access Key (optional if using IAM)', 'placeholder': 'accesskeyhere', 'kind': 'text', 'optional': true}, + {'name': 's3_secret_key', 'title': 'AWS Secret Key (optional if using IAM)', 'placeholder': 'secretkeyhere', 'kind': 'text', 'optional': true}, + {'name': 'host', 'title': 'S3 Host (optional)', 'placeholder': 's3.amazonaws.com', 'kind': 'text', 'optional': true}, + {'name': 'port', 'title': 'S3 Port (optional)', 'placeholder': '443', 'kind': 'text', 'pattern': '^[0-9]+$', 'optional': true}, + + {'name': 'cloudfront_distribution_domain', 'title': 'CloudFront Distribution Domain Name', 'placeholder': 'somesubdomain.cloudfront.net', 'pattern': '^([0-9a-zA-Z]+\\.)+[0-9a-zA-Z]+$', 'kind': 'text'}, + {'name': 'cloudfront_key_id', 'title': 'CloudFront Key ID', 'placeholder': 'APKATHISISAKEYID', 'kind': 'text'}, + {'name': 'cloudfront_privatekey_filename', 'title': 'CloudFront Private Key', 'filesuffix': 'cloudfront-signing-key.pem', 'kind': 'file'}, + ], + }; + + $scope.enableFeature = function(config, feature) { + config[feature] = true; + }; + + $scope.validateHostname = function(hostname) { + if (hostname.indexOf('127.0.0.1') == 0 || hostname.indexOf('localhost') == 0) { + return 'Please specify a non-localhost hostname. "localhost" will refer to the container, not your machine.' + } + + return null; + }; + + $scope.config = null; + $scope.mapped = { + '$hasChanges': false + }; + + $scope.hasfile = {}; + $scope.validating = null; + $scope.savingConfiguration = false; + + $scope.removeOIDCProvider = function(provider) { + delete $scope.config[provider]; + }; + + $scope.addOIDCProvider = () => { + bootbox.prompt('Enter an ID for the OIDC provider', function(result) { + if (!result) { + return; + } + + result = result.toUpperCase(); + + if (!result.match(/^[A-Z0-9]+$/)) { + bootbox.alert('Invalid ID for OIDC provider: must be alphanumeric'); + return; + } + + if (result == 'GITHUB' || result == 'GOOGLE') { + bootbox.alert('Invalid ID for OIDC provider: cannot be a reserved name'); + return; + } + + var key = result + '_LOGIN_CONFIG'; + if ($scope.config[key]) { + bootbox.alert('Invalid ID for OIDC provider: already exists'); + return; + } + + $scope.config[key] = {}; + }); + }; + + $scope.getOIDCProviderId = function(key) { + var index = key.indexOf('_LOGIN_CONFIG'); + if (index <= 0) { + return null; + } + + return key.substr(0, index).toLowerCase(); + }; + + $scope.getOIDCProviders = function(config) { + var keys = Object.keys(config || {}); + return keys.filter(function(key) { + if (key == 'GITHUB_LOGIN_CONFIG' || key == 'GOOGLE_LOGIN_CONFIG') { + // Has custom UI and config. + return false; + } + + return !!$scope.getOIDCProviderId(key); + }); + }; + + $scope.getServices = function(config) { + var services = []; + if (!config) { return services; } + + for (var i = 0; i < $scope.SERVICES.length; ++i) { + var service = $scope.SERVICES[i]; + if (!service.condition || service.condition(config)) { + services.push({ + 'service': service, + 'status': 'validating' + }); + } + } + + return services; + }; + + $scope.validationStatus = function(serviceInfos) { + if (!serviceInfos) { return 'validating'; } + + var hasError = false; + for (var i = 0; i < serviceInfos.length; ++i) { + if (serviceInfos[i].status == 'validating') { + return 'validating'; + } + if (serviceInfos[i].status == 'error') { + hasError = true; + } + } + + return hasError ? 'failed' : 'success'; + }; + + $scope.cancelValidation = function() { + $('#validateAndSaveModal').modal('hide'); + $scope.validating = null; + $scope.savingConfiguration = false; + }; + + $scope.validateService = function(serviceInfo, opt_password) { + var params = { + 'service': serviceInfo.service.id + }; + + var data = { + 'config': $scope.config, + 'password': opt_password || '' + }; + + var errorDisplay = ApiService.errorDisplay( + 'Could not validate configuration. Please report this error.', + function() { + authPassword = null; + }); + + ApiService.scValidateConfig(data, params).then(function(resp) { + serviceInfo.status = resp.status ? 'success' : 'error'; + serviceInfo.errorMessage = $.trim(resp.reason || ''); + + if (!resp.status) { + authPassword = null; + } + + }, errorDisplay); + }; + + $scope.checkValidateAndSave = function() { + if ($scope.configform.$valid) { + saveStorageConfig(); + $scope.validateAndSave(); + return; + } + + var query = $element.find("input.ng-invalid:first"); + + if (query && query.length) { + query[0].scrollIntoView(); + query.focus(); + } + }; + + $scope.validateAndSave = function() { + $scope.validating = $scope.getServices($scope.config); + + var requirePassword = false; + for (var i = 0; i < $scope.validating.length; ++i) { + var serviceInfo = $scope.validating[i]; + if (serviceInfo.service.password) { + requirePassword = true; + break; + } + } + + if (!requirePassword) { + $scope.performValidateAndSave(); + return; + } + + var box = bootbox.dialog({ + "message": 'Please enter your superuser password to validate your auth configuration:' + + '
' + + '' + + '
', + "title": 'Enter Password', + "buttons": { + "success": { + "label": "Validate Config", + "className": "btn-success btn-continue", + "callback": function() { + $scope.performValidateAndSave($('#validatePassword').val()); + } + }, + "close": { + "label": "Cancel", + "className": "btn-default", + "callback": function() { + } + } + } + }); + + box.bind('shown.bs.modal', function(){ + box.find("input").focus(); + box.find("form").submit(function() { + if (!$('#validatePassword').val()) { return; } + box.modal('hide'); + }); + }); + }; + + $scope.performValidateAndSave = function(opt_password) { + $scope.savingConfiguration = false; + $scope.validating = $scope.getServices($scope.config); + + authPassword = opt_password; + + $('#validateAndSaveModal').modal({ + keyboard: false, + backdrop: 'static' + }); + + for (var i = 0; i < $scope.validating.length; ++i) { + var serviceInfo = $scope.validating[i]; + $scope.validateService(serviceInfo, opt_password); + } + }; + + $scope.saveConfiguration = function() { + $scope.savingConfiguration = true; + + // Make sure to note that fully verified setup is completed. We use this as a signal + // in the setup tool. + $scope.config['SETUP_COMPLETE'] = true; + + var data = { + 'config': $scope.config, + 'hostname': window.location.host, + 'password': authPassword || '' + }; + + var errorDisplay = ApiService.errorDisplay( + 'Could not save configuration. Please report this error.', + function() { + authPassword = null; + }); + + ApiService.scUpdateConfig(data).then(function(resp) { + authPassword = null; + + $scope.savingConfiguration = false; + $scope.mapped.$hasChanges = false; + + $('#validateAndSaveModal').modal('hide'); + + $scope.configurationSaved({'config': $scope.config}); + }, errorDisplay); + }; + + // Convert storage config to an array + var initializeStorageConfig = function($scope) { + var config = $scope.config.DISTRIBUTED_STORAGE_CONFIG || {}; + var defaultLocations = $scope.config.DISTRIBUTED_STORAGE_DEFAULT_LOCATIONS || []; + var preference = $scope.config.DISTRIBUTED_STORAGE_PREFERENCE || []; + + $scope.serverStorageConfig = angular.copy(config); + $scope.storageConfig = []; + + Object.keys(config).forEach(function(location) { + $scope.storageConfig.push({ + location: location, + defaultLocation: defaultLocations.indexOf(location) >= 0, + data: angular.copy(config[location]), + error: {}, + }); + }); + + if (!$scope.storageConfig.length) { + $scope.addStorageConfig('default'); + return; + } + + // match DISTRIBUTED_STORAGE_PREFERENCE order first, remaining are + // ordered by unicode point value + $scope.storageConfig.sort(function(a, b) { + var indexA = preference.indexOf(a.location); + var indexB = preference.indexOf(b.location); + + if (indexA > -1 && indexB > -1) return indexA < indexB ? -1 : 1; + if (indexA > -1) return -1; + if (indexB > -1) return 1; + + return a.location < b.location ? -1 : 1; + }); + }; + + $scope.allowChangeLocationStorageConfig = function(location) { + if (!$scope.serverStorageConfig[location]) { return true }; + + // allow user to change location ID if another exists with the same ID + return $scope.storageConfig.filter(function(sc) { + return sc.location === location; + }).length >= 2; + }; + + $scope.allowRemoveStorageConfig = function(location) { + return $scope.storageConfig.length > 1 && $scope.allowChangeLocationStorageConfig(location); + }; + + $scope.canAddStorageConfig = function() { + return $scope.config && + $scope.config.FEATURE_STORAGE_REPLICATION && + $scope.storageConfig && + (!$scope.storageConfig.length || $scope.storageConfig.length < 10); + }; + + $scope.addStorageConfig = function(location) { + var storageType = 'LocalStorage'; + + // Use last storage type by default + if ($scope.storageConfig.length) { + storageType = $scope.storageConfig[$scope.storageConfig.length-1].data[0]; + } + + $scope.storageConfig.push({ + location: location || '', + defaultLocation: false, + data: [storageType, {}], + error: {}, + }); + }; + + $scope.removeStorageConfig = function(sc) { + $scope.storageConfig.splice($scope.storageConfig.indexOf(sc), 1); + }; + + var saveStorageConfig = function() { + var config = {}; + var defaultLocations = []; + var preference = []; + + $scope.storageConfig.forEach(function(sc) { + config[sc.location] = sc.data; + if (sc.defaultLocation) defaultLocations.push(sc.location); + preference.push(sc.location); + }); + + $scope.config.DISTRIBUTED_STORAGE_CONFIG = config; + $scope.config.DISTRIBUTED_STORAGE_DEFAULT_LOCATIONS = defaultLocations; + $scope.config.DISTRIBUTED_STORAGE_PREFERENCE = preference; + }; + + var gitlabSelector = function(key) { + return function(value) { + if (!value || !$scope.config) { return; } + + if (!$scope.config[key]) { + $scope.config[key] = {}; + } + + if (value == 'enterprise') { + if ($scope.config[key]['GITLAB_ENDPOINT'] == 'https://gitlab.com/') { + $scope.config[key]['GITLAB_ENDPOINT'] = ''; + } + } else if (value == 'hosted') { + $scope.config[key]['GITLAB_ENDPOINT'] = 'https://gitlab.com/'; + } + }; + }; + + var githubSelector = function(key) { + return function(value) { + if (!value || !$scope.config) { return; } + + if (!$scope.config[key]) { + $scope.config[key] = {}; + } + + if (value == 'enterprise') { + if ($scope.config[key]['GITHUB_ENDPOINT'] == 'https://github.com/') { + $scope.config[key]['GITHUB_ENDPOINT'] = ''; + } + delete $scope.config[key]['API_ENDPOINT']; + } else if (value == 'hosted') { + $scope.config[key]['GITHUB_ENDPOINT'] = 'https://github.com/'; + $scope.config[key]['API_ENDPOINT'] = 'https://api.github.com/'; + } + }; + }; + + var getKey = function(config, path) { + if (!config) { + return null; + } + + var parts = path.split('.'); + var current = config; + for (var i = 0; i < parts.length; ++i) { + var part = parts[i]; + if (!current[part]) { return null; } + current = current[part]; + } + return current; + }; + + var initializeMappedLogic = function(config) { + var gle = getKey(config, 'GITHUB_LOGIN_CONFIG.GITHUB_ENDPOINT'); + var gte = getKey(config, 'GITHUB_TRIGGER_CONFIG.GITHUB_ENDPOINT'); + + $scope.mapped['GITHUB_LOGIN_KIND'] = gle == 'https://github.com/' ? 'hosted' : 'enterprise'; + $scope.mapped['GITHUB_TRIGGER_KIND'] = gte == 'https://github.com/' ? 'hosted' : 'enterprise'; + + var glabe = getKey(config, 'GITLAB_TRIGGER_KIND.GITHUB_ENDPOINT'); + $scope.mapped['GITLAB_TRIGGER_KIND'] = glabe == 'https://gitlab.com/' ? 'hosted' : 'enterprise'; + + $scope.mapped['redis'] = {}; + $scope.mapped['redis']['host'] = getKey(config, 'BUILDLOGS_REDIS.host') || getKey(config, 'USER_EVENTS_REDIS.host'); + $scope.mapped['redis']['port'] = getKey(config, 'BUILDLOGS_REDIS.port') || getKey(config, 'USER_EVENTS_REDIS.port'); + $scope.mapped['redis']['password'] = getKey(config, 'BUILDLOGS_REDIS.password') || getKey(config, 'USER_EVENTS_REDIS.password'); + + $scope.mapped['TLS_SETTING'] = 'none'; + if (config['PREFERRED_URL_SCHEME'] == 'https') { + if (config['EXTERNAL_TLS_TERMINATION'] === true) { + $scope.mapped['TLS_SETTING'] = 'external-tls'; + } else { + $scope.mapped['TLS_SETTING'] = 'internal-tls'; + } + } + }; + + var tlsSetter = function(value) { + if (value == null || !$scope.config) { return; } + + switch (value) { + case 'none': + $scope.config['PREFERRED_URL_SCHEME'] = 'http'; + delete $scope.config['EXTERNAL_TLS_TERMINATION']; + return; + + case 'external-tls': + $scope.config['PREFERRED_URL_SCHEME'] = 'https'; + $scope.config['EXTERNAL_TLS_TERMINATION'] = true; + return; + + case 'internal-tls': + $scope.config['PREFERRED_URL_SCHEME'] = 'https'; + delete $scope.config['EXTERNAL_TLS_TERMINATION']; + return; + } + }; + + var redisSetter = function(keyname) { + return function(value) { + if (value == null || !$scope.config) { return; } + + if (!$scope.config['BUILDLOGS_REDIS']) { + $scope.config['BUILDLOGS_REDIS'] = {}; + } + + if (!$scope.config['USER_EVENTS_REDIS']) { + $scope.config['USER_EVENTS_REDIS'] = {}; + } + + if (!value) { + delete $scope.config['BUILDLOGS_REDIS'][keyname]; + delete $scope.config['USER_EVENTS_REDIS'][keyname]; + return; + } + + $scope.config['BUILDLOGS_REDIS'][keyname] = value; + $scope.config['USER_EVENTS_REDIS'][keyname] = value; + }; + }; + + // Add mapped logic. + $scope.$watch('mapped.GITHUB_LOGIN_KIND', githubSelector('GITHUB_LOGIN_CONFIG')); + $scope.$watch('mapped.GITHUB_TRIGGER_KIND', githubSelector('GITHUB_TRIGGER_CONFIG')); + $scope.$watch('mapped.GITLAB_TRIGGER_KIND', gitlabSelector('GITLAB_TRIGGER_KIND')); + $scope.$watch('mapped.TLS_SETTING', tlsSetter); + + $scope.$watch('mapped.redis.host', redisSetter('host')); + $scope.$watch('mapped.redis.port', redisSetter('port')); + $scope.$watch('mapped.redis.password', redisSetter('password')); + + // Remove extra extra fields (which are not allowed) from storage config. + var updateFields = function(sc) { + var type = sc.data[0]; + var configObject = sc.data[1]; + var allowedFields = $scope.STORAGE_CONFIG_FIELDS[type]; + + // Remove any fields not allowed. + for (var fieldName in configObject) { + if (!configObject.hasOwnProperty(fieldName)) { + continue; + } + + var isValidField = $.grep(allowedFields, function(field) { + return field.name == fieldName; + }).length > 0; + + if (!isValidField) { + delete configObject[fieldName]; + } + } + + // Set any missing boolean fields to false. + for (var i = 0; i < allowedFields.length; ++i) { + if (allowedFields[i].kind == 'bool') { + configObject[allowedFields[i].name] = configObject[allowedFields[i].name] || false; + } + } + }; + + // Validate and update storage config on update. + var refreshStorageConfig = function() { + if (!$scope.config || !$scope.storageConfig) return; + + var locationCounts = {}; + var errors = []; + var valid = true; + + $scope.storageConfig.forEach(function(sc) { + // remove extra fields from storage config + updateFields(sc); + + if (!locationCounts[sc.location]) locationCounts[sc.location] = 0; + locationCounts[sc.location]++; + }); + + // validate storage config + $scope.storageConfig.forEach(function(sc) { + var error = {}; + + if ($scope.config.FEATURE_STORAGE_REPLICATION && sc.data[0] === 'LocalStorage') { + error.engine = 'Replication to a locally mounted directory is unsupported as it is only accessible on a single machine.'; + valid = false; + } + + if (locationCounts[sc.location] > 1) { + error.location = 'Location ID must be unique.'; + valid = false; + } + + errors.push(error); + }); + + $scope.storageConfigError = errors; + $scope.configform.$setValidity('storageConfig', valid); + }; + + $scope.$watch('config.INTERNAL_OIDC_SERVICE_ID', function(service_id) { + if (service_id) { + $scope.config['FEATURE_DIRECT_LOGIN'] = false; + } + }); + + $scope.$watch('config.FEATURE_STORAGE_REPLICATION', function() { + refreshStorageConfig(); + }); + + $scope.$watch('storageConfig', function() { + refreshStorageConfig(); + }, true); + + $scope.$watch('config', function(value) { + $scope.mapped['$hasChanges'] = true; + }, true); + + $scope.$watch('isActive', function(value) { + if (!value) { return; } + + ApiService.scGetConfig().then(function(resp) { + $scope.config = resp['config'] || {}; + initializeMappedLogic($scope.config); + initializeStorageConfig($scope); + $scope.mapped['$hasChanges'] = false; + }, ApiService.errorDisplay('Could not load config')); + }); + } + }; + + return directiveDefinitionObject; + }) + + .directive('configParsedField', function ($timeout) { + var directiveDefinitionObject = { + priority: 0, + templateUrl: urlParsedField, + replace: false, + transclude: true, + restrict: 'C', + scope: { + 'binding': '=binding', + 'parser': '&parser', + 'serializer': '&serializer' + }, + controller: function($scope, $element, $transclude) { + $scope.childScope = null; + + $transclude(function(clone, scope) { + $scope.childScope = scope; + $scope.childScope['fields'] = {}; + $element.append(clone); + }); + + $scope.childScope.$watch('fields', function(value) { + // Note: We need the timeout here because Angular starts the digest of the + // parent scope AFTER the child scope, which means it can end up one action + // behind. The timeout ensures that the parent scope will be fully digest-ed + // and then we update the binding. Yes, this is a hack :-/. + $timeout(function() { + $scope.binding = $scope.serializer({'fields': value}); + }); + }, true); + + $scope.$watch('binding', function(value) { + var parsed = $scope.parser({'value': value}); + for (var key in parsed) { + if (parsed.hasOwnProperty(key)) { + $scope.childScope['fields'][key] = parsed[key]; + } + } + }); + } + }; + return directiveDefinitionObject; + }) + + .directive('configVariableField', function () { + var directiveDefinitionObject = { + priority: 0, + templateUrl: urlVarField, + replace: false, + transclude: true, + restrict: 'C', + scope: { + 'binding': '=binding' + }, + controller: function($scope, $element) { + $scope.sections = {}; + $scope.currentSection = null; + + $scope.setSection = function(section) { + $scope.binding = section.value; + }; + + this.addSection = function(section, element) { + $scope.sections[section.value] = { + 'title': section.valueTitle, + 'value': section.value, + 'element': element + }; + + element.hide(); + + if (!$scope.binding) { + $scope.binding = section.value; + } + }; + + $scope.$watch('binding', function(binding) { + if (!binding) { return; } + + if ($scope.currentSection) { + $scope.currentSection.element.hide(); + } + + if ($scope.sections[binding]) { + $scope.sections[binding].element.show(); + $scope.currentSection = $scope.sections[binding]; + } + }); + } + }; + return directiveDefinitionObject; + }) + + .directive('variableSection', function () { + var directiveDefinitionObject = { + priority: 0, + templateUrl: urlVarField, + priority: 1, + require: '^configVariableField', + replace: false, + transclude: true, + restrict: 'C', + scope: { + 'value': '@value', + 'valueTitle': '@valueTitle' + }, + controller: function($scope, $element) { + var parentCtrl = $element.parent().controller('configVariableField'); + parentCtrl.addSection($scope, $element); + } + }; + return directiveDefinitionObject; + }) + + .directive('configListField', function () { + var directiveDefinitionObject = { + priority: 0, + templateUrl: urlListField, + replace: false, + transclude: false, + restrict: 'C', + scope: { + 'binding': '=binding', + 'placeholder': '@placeholder', + 'defaultValue': '@defaultValue', + 'itemTitle': '@itemTitle', + 'itemPattern': '@itemPattern' + }, + controller: function($scope, $element) { + $scope.removeItem = function(item) { + var index = $scope.binding.indexOf(item); + if (index >= 0) { + $scope.binding.splice(index, 1); + } + }; + + $scope.addItem = function() { + if (!$scope.newItemName) { + return; + } + + if (!$scope.binding) { + $scope.binding = []; + } + + if ($scope.binding.indexOf($scope.newItemName) >= 0) { + return; + } + + $scope.binding.push($scope.newItemName); + $scope.newItemName = null; + }; + + $scope.patternMap = {}; + + $scope.getRegexp = function(pattern) { + if (!pattern) { + pattern = '.*'; + } + + if ($scope.patternMap[pattern]) { + return $scope.patternMap[pattern]; + } + + return $scope.patternMap[pattern] = new RegExp(pattern); + }; + + $scope.$watch('binding', function(binding) { + if (!binding && $scope.defaultValue) { + $scope.binding = eval($scope.defaultValue); + } + }); + } + }; + return directiveDefinitionObject; + }) + + .directive('configFileField', function () { + var directiveDefinitionObject = { + priority: 0, + templateUrl: urlFileField, + replace: false, + transclude: false, + restrict: 'C', + scope: { + 'filename': '@filename', + 'skipCheckFile': '@skipCheckFile', + 'hasFile': '=hasFile', + 'binding': '=?binding' + }, + controller: function($scope, $element, Restangular, $upload) { + $scope.hasFile = false; + + var setHasFile = function(hasFile) { + $scope.hasFile = hasFile; + $scope.binding = hasFile ? $scope.filename : null; + }; + + $scope.onFileSelect = function(files) { + if (files.length < 1) { + setHasFile(false); + return; + } + + $scope.uploadProgress = 0; + $scope.upload = $upload.upload({ + url: '/api/v1/superuser/config/file/' + $scope.filename, + method: 'POST', + data: {'_csrf_token': window.__token}, + file: files[0], + }).progress(function(evt) { + $scope.uploadProgress = parseInt(100.0 * evt.loaded / evt.total); + if ($scope.uploadProgress == 100) { + $scope.uploadProgress = null; + setHasFile(true); + } + }).success(function(data, status, headers, config) { + $scope.uploadProgress = null; + setHasFile(true); + }); + }; + + var loadStatus = function(filename) { + Restangular.one('superuser/config/file/' + filename).get().then(function(resp) { + setHasFile(false); + }); + }; + + if ($scope.filename && $scope.skipCheckFile != "true") { + loadStatus($scope.filename); + } + } + }; + return directiveDefinitionObject; + }) + + .directive('configBoolField', function () { + var directiveDefinitionObject = { + priority: 0, + templateUrl: urlBoolField, + replace: false, + transclude: true, + restrict: 'C', + scope: { + 'binding': '=binding' + }, + controller: function($scope, $element) { + } + }; + return directiveDefinitionObject; + }) + + .directive('configNumericField', function () { + var directiveDefinitionObject = { + priority: 0, + templateUrl: urlNumericField, + replace: false, + transclude: false, + restrict: 'C', + scope: { + 'binding': '=binding', + 'placeholder': '@placeholder', + 'defaultValue': '@defaultValue', + }, + controller: function($scope, $element) { + $scope.bindinginternal = 0; + + $scope.$watch('binding', function(binding) { + if ($scope.binding == 0 && $scope.defaultValue) { + $scope.binding = $scope.defaultValue * 1; + } + + $scope.bindinginternal = $scope.binding; + }); + + $scope.$watch('bindinginternal', function(binding) { + var newValue = $scope.bindinginternal * 1; + if (isNaN(newValue)) { + newValue = 0; + } + $scope.binding = newValue; + }); + } + }; + return directiveDefinitionObject; + }) + + .directive('configContactsField', function () { + var directiveDefinitionObject = { + priority: 0, + templateUrl: urlContactsField, + priority: 1, + replace: false, + transclude: false, + restrict: 'C', + scope: { + 'binding': '=binding' + }, + controller: function($scope, $element) { + var padItems = function(items) { + // Remove the last item if both it and the second to last items are empty. + if (items.length > 1 && !items[items.length - 2].value && !items[items.length - 1].value) { + items.splice(items.length - 1, 1); + return; + } + + // If the last item is non-empty, add a new item. + if (items.length == 0 || items[items.length - 1].value) { + items.push({'value': ''}); + return; + } + }; + + $scope.itemHash = null; + $scope.$watch('items', function(items) { + if (!items) { return; } + padItems(items); + + var itemHash = ''; + var binding = []; + for (var i = 0; i < items.length; ++i) { + var item = items[i]; + if (item.value && (URI(item.value).host() || URI(item.value).path())) { + binding.push(item.value); + itemHash += item.value; + } + } + + $scope.itemHash = itemHash; + $scope.binding = binding; + }, true); + + $scope.$watch('binding', function(binding) { + var current = binding || []; + var items = []; + var itemHash = ''; + for (var i = 0; i < current.length; ++i) { + items.push({'value': current[i]}) + itemHash += current[i]; + } + + if ($scope.itemHash != itemHash) { + $scope.items = items; + } + }); + } + }; + return directiveDefinitionObject; + }) + + .directive('configContactField', function () { + var directiveDefinitionObject = { + priority: 0, + templateUrl: urlContactsField, + priority: 1, + replace: false, + transclude: true, + restrict: 'C', + scope: { + 'binding': '=binding' + }, + controller: function($scope, $element) { + $scope.kind = null; + $scope.value = null; + + var updateBinding = function() { + if ($scope.value == null) { return; } + var value = $scope.value || ''; + + switch ($scope.kind) { + case 'mailto': + $scope.binding = 'mailto:' + value; + return; + + case 'tel': + $scope.binding = 'tel:' + value; + return; + + case 'irc': + $scope.binding = 'irc://' + value; + return; + + default: + $scope.binding = value; + return; + } + }; + + $scope.$watch('kind', updateBinding); + $scope.$watch('value', updateBinding); + + $scope.$watch('binding', function(value) { + if (!value) { + $scope.kind = null; + $scope.value = null; + return; + } + + var uri = URI(value); + $scope.kind = uri.scheme(); + + switch ($scope.kind) { + case 'mailto': + case 'tel': + $scope.value = uri.path(); + break; + + case 'irc': + $scope.value = value.substr('irc://'.length); + break; + + default: + $scope.kind = 'http'; + $scope.value = value; + break; + } + }); + + $scope.getPlaceholder = function(kind) { + switch (kind) { + case 'mailto': + return 'some@example.com'; + + case 'tel': + return '555-555-5555'; + + case 'irc': + return 'myserver:port/somechannel'; + + default: + return 'http://some/url'; + } + }; + } + }; + return directiveDefinitionObject; + }) + + .directive('configMapField', function () { + var directiveDefinitionObject = { + priority: 0, + templateUrl: urlMapField, + replace: false, + transclude: false, + restrict: 'C', + scope: { + 'binding': '=binding', + 'keys': '=keys' + }, + controller: function($scope, $element) { + $scope.newKey = null; + $scope.newValue = null; + + $scope.hasValues = function(binding) { + return binding && Object.keys(binding).length; + }; + + $scope.removeKey = function(key) { + delete $scope.binding[key]; + }; + + $scope.addEntry = function() { + if (!$scope.newKey || !$scope.newValue) { return; } + + $scope.binding = $scope.binding || {}; + $scope.binding[$scope.newKey] = $scope.newValue; + $scope.newKey = null; + $scope.newValue = null; + } + } + }; + return directiveDefinitionObject; + }) + + .directive('configServiceKeyField', function (ApiService) { + var directiveDefinitionObject = { + priority: 0, + templateUrl: urlServiceKeyField, + replace: false, + transclude: false, + restrict: 'C', + scope: { + 'serviceName': '@serviceName', + }, + controller: function($scope, $element) { + $scope.foundKeys = []; + $scope.loading = false; + $scope.loadError = false; + $scope.hasValidKey = false; + $scope.hasValidKeyStr = null; + + $scope.updateKeys = function() { + $scope.foundKeys = []; + $scope.loading = true; + + ApiService.listServiceKeys().then(function(resp) { + $scope.loading = false; + $scope.loadError = false; + + resp['keys'].forEach(function(key) { + if (key['service'] == $scope.serviceName) { + $scope.foundKeys.push(key); + } + }); + + $scope.hasValidKey = checkValidKey($scope.foundKeys); + $scope.hasValidKeyStr = $scope.hasValidKey ? 'true' : ''; + }, function() { + $scope.loading = false; + $scope.loadError = true; + }); + }; + + // Perform initial loading of the keys. + $scope.updateKeys(); + + $scope.isKeyExpired = function(key) { + if (key.expiration_date != null) { + var expiration_date = moment(key.expiration_date); + return moment().isAfter(expiration_date); + } + return false; + }; + + $scope.showRequestServiceKey = function() { + $scope.requestKeyInfo = { + 'service': $scope.serviceName + }; + }; + + $scope.handleKeyCreated = function() { + $scope.updateKeys(); + }; + + var checkValidKey = function(keys) { + for (var i = 0; i < keys.length; ++i) { + var key = keys[i]; + if (!key.approval) { + continue; + } + + if ($scope.isKeyExpired(key)) { + continue; + } + + return true; + } + + return false; + }; + } + }; + return directiveDefinitionObject; + }) + + .directive('configStringField', function () { + var directiveDefinitionObject = { + priority: 0, + templateUrl: urlStringField, + replace: false, + transclude: false, + restrict: 'C', + scope: { + 'binding': '=binding', + 'placeholder': '@placeholder', + 'pattern': '@pattern', + 'defaultValue': '@defaultValue', + 'validator': '&validator', + 'isOptional': '=isOptional' + }, + controller: function($scope, $element) { + var firstSet = true; + + $scope.patternMap = {}; + + $scope.getRegexp = function(pattern) { + if (!pattern) { + pattern = '.*'; + } + + if ($scope.patternMap[pattern]) { + return $scope.patternMap[pattern]; + } + + return $scope.patternMap[pattern] = new RegExp(pattern); + }; + + $scope.$watch('binding', function(binding) { + if (firstSet && !binding && $scope.defaultValue) { + $scope.binding = $scope.defaultValue; + firstSet = false; + } + + $scope.errorMessage = $scope.validator({'value': binding || ''}); + }); + } + }; + return directiveDefinitionObject; + }) + + .directive('configStringListField', function () { + var directiveDefinitionObject = { + priority: 0, + templateUrl: urlStringListField, + replace: false, + transclude: false, + restrict: 'C', + scope: { + 'binding': '=binding', + 'itemTitle': '@itemTitle', + 'itemDelimiter': '@itemDelimiter', + 'placeholder': '@placeholder', + 'isOptional': '=isOptional' + }, + controller: function($scope, $element) { + $scope.$watch('internalBinding', function(value) { + if (value) { + $scope.binding = value.split($scope.itemDelimiter); + } + }); + + $scope.$watch('binding', function(value) { + if (value) { + $scope.internalBinding = value.join($scope.itemDelimiter); + } + }); + } + }; + return directiveDefinitionObject; + }) + + .directive('configCertificatesField', function () { + var directiveDefinitionObject = { + priority: 0, + templateUrl: urlCertField, + replace: false, + transclude: false, + restrict: 'C', + scope: { + }, + controller: function($scope, $element, $upload, ApiService, UserService) { + $scope.resetUpload = 0; + $scope.certsUploading = false; + + var loadCertificates = function() { + $scope.certificatesResource = ApiService.getCustomCertificatesAsResource().get(function(resp) { + $scope.certInfo = resp; + $scope.certsUploading = false; + }); + }; + + UserService.updateUserIn($scope, function(user) { + if (!user.anonymous) { + loadCertificates(); + } + }); + + $scope.handleCertsSelected = function(files, callback) { + $scope.certsUploading = true; + $upload.upload({ + url: '/api/v1/superuser/customcerts/' + files[0].name, + method: 'POST', + data: {'_csrf_token': window.__token}, + file: files[0] + }).success(function() { + callback(true); + $scope.resetUpload++; + loadCertificates(); + }).error(function(r) { + bootbox.alert('Could not upload certificate') + callback(false); + $scope.resetUpload++; + loadCertificates(); + }); + }; + + $scope.deleteCert = function(path) { + var errorDisplay = ApiService.errorDisplay('Could not delete certificate'); + var params = { + 'certpath': path + }; + + ApiService.deleteCustomCertificate(null, params).then(loadCertificates, errorDisplay); + }; + } + }; + return directiveDefinitionObject; + }); diff --git a/config_app/js/main.ts b/config_app/js/main.ts new file mode 100644 index 000000000..9ca931a8d --- /dev/null +++ b/config_app/js/main.ts @@ -0,0 +1,36 @@ +// imports shims, etc +import 'core-js'; + +import '../static/css/core-ui.css'; + +import * as angular from 'angular'; +import { ConfigAppModule } from './config-app.module'; +import { bundle } from 'ng-metadata/core'; + +// load all app dependencies +require('../static/lib/angular-file-upload.min.js'); +require('../../static/js/tar'); + +const ng1QuayModule: string = bundle(ConfigAppModule, []).name; +angular.module('quay-config', [ng1QuayModule]) + .run(() => { + console.log(' init run was called') + }); + +console.log('Hello world! I\'m the config app'); + +declare var require: any; +function requireAll(r) { + r.keys().forEach(r); +} + +// load all services +// require('./services/api-service'); +requireAll(require.context('./services', true, /\.js$/)); + + +// load all the components after services +requireAll(require.context('./setup', true, /\.js$/)); +requireAll(require.context('./core-config-setup', true, /\.js$/)); + + diff --git a/config_app/js/services/api-service.js b/config_app/js/services/api-service.js new file mode 100644 index 000000000..eaa16b746 --- /dev/null +++ b/config_app/js/services/api-service.js @@ -0,0 +1,332 @@ +/** + * Service which exposes the server-defined API as a nice set of helper methods and automatic + * callbacks. Any method defined on the server is exposed here as an equivalent method. Also + * defines some helper functions for working with API responses. + */ +// console.log(angular.module('quay-config').requires); +angular.module('quay-config').factory('ApiService', ['Restangular', '$q', 'UtilService', function(Restangular, $q, UtilService) { + var apiService = {}; + + // if (!window.__endpoints) { + // return apiService; + // } + + var getResource = function(getMethod, operation, opt_parameters, opt_background) { + var resource = {}; + resource.withOptions = function(options) { + this.options = options; + return this; + }; + + resource.get = function(processor, opt_errorHandler) { + var options = this.options; + var result = { + 'loading': true, + 'value': null, + 'hasError': false + }; + + getMethod(options, opt_parameters, opt_background, true).then(function(resp) { + result.value = processor(resp); + result.loading = false; + }, function(resp) { + result.hasError = true; + result.loading = false; + if (opt_errorHandler) { + opt_errorHandler(resp); + } + }); + + return result; + }; + + return resource; + }; + + var buildUrl = function(path, parameters) { + // We already have /api/v1/ on the URLs, so remove them from the paths. + path = path.substr('/api/v1/'.length, path.length); + + // Build the path, adjusted with the inline parameters. + var used = {}; + var url = ''; + for (var i = 0; i < path.length; ++i) { + var c = path[i]; + if (c == '{') { + var end = path.indexOf('}', i); + var varName = path.substr(i + 1, end - i - 1); + + if (!parameters[varName]) { + throw new Error('Missing parameter: ' + varName); + } + + used[varName] = true; + url += parameters[varName]; + i = end; + continue; + } + + url += c; + } + + // Append any query parameters. + var isFirst = true; + for (var paramName in parameters) { + if (!parameters.hasOwnProperty(paramName)) { continue; } + if (used[paramName]) { continue; } + + var value = parameters[paramName]; + if (value) { + url += isFirst ? '?' : '&'; + url += paramName + '=' + encodeURIComponent(value) + isFirst = false; + } + } + + return url; + }; + + var getGenericOperationName = function(userOperationName) { + return userOperationName.replace('User', ''); + }; + + var getMatchingUserOperationName = function(orgOperationName, method, userRelatedResource) { + if (userRelatedResource) { + if (userRelatedResource[method.toLowerCase()]) { + return userRelatedResource[method.toLowerCase()]['operationId']; + } + } + + throw new Error('Could not find user operation matching org operation: ' + orgOperationName); + }; + + var freshLoginInProgress = []; + var reject = function(msg) { + for (var i = 0; i < freshLoginInProgress.length; ++i) { + freshLoginInProgress[i].deferred.reject({'data': {'message': msg}}); + } + freshLoginInProgress = []; + }; + + var retry = function() { + for (var i = 0; i < freshLoginInProgress.length; ++i) { + freshLoginInProgress[i].retry(); + } + freshLoginInProgress = []; + }; + + var freshLoginFailCheck = function(opName, opArgs) { + return function(resp) { + var deferred = $q.defer(); + + // If the error is a fresh login required, show the dialog. + // TODO: remove error_type (old style error) + var fresh_login_required = resp.data['title'] == 'fresh_login_required' || resp.data['error_type'] == 'fresh_login_required'; + if (resp.status == 401 && fresh_login_required) { + var retryOperation = function() { + apiService[opName].apply(apiService, opArgs).then(function(resp) { + deferred.resolve(resp); + }, function(resp) { + deferred.reject(resp); + }); + }; + + var verifyNow = function() { + if (!$('#freshPassword').val()) { + return; + } + + var info = { + 'password': $('#freshPassword').val() + }; + + $('#freshPassword').val(''); + + // Conduct the sign in of the user. + apiService.verifyUser(info).then(function() { + // On success, retry the operations. if it succeeds, then resolve the + // deferred promise with the result. Otherwise, reject the same. + retry(); + }, function(resp) { + // Reject with the sign in error. + reject('Invalid verification credentials'); + }); + }; + + // Add the retry call to the in progress list. If there is more than a single + // in progress call, we skip showing the dialog (since it has already been + // shown). + freshLoginInProgress.push({ + 'deferred': deferred, + 'retry': retryOperation + }) + + if (freshLoginInProgress.length > 1) { + return deferred.promise; + } + + var box = bootbox.dialog({ + "message": 'It has been more than a few minutes since you last logged in, ' + + 'so please verify your password to perform this sensitive operation:' + + '
' + + '' + + '
', + "title": 'Please Verify', + "buttons": { + "verify": { + "label": "Verify", + "className": "btn-success btn-continue", + "callback": verifyNow + }, + "close": { + "label": "Cancel", + "className": "btn-default", + "callback": function() { + reject('Verification canceled') + } + } + } + }); + + box.bind('shown.bs.modal', function(){ + box.find("input").focus(); + box.find("form").submit(function() { + if (!$('#freshPassword').val()) { return; } + + box.modal('hide'); + verifyNow(); + }); + }); + + // Return a new promise. We'll accept or reject it based on the result + // of the login. + return deferred.promise; + } + + // Otherwise, we just 'raise' the error via the reject method on the promise. + return $q.reject(resp); + }; + }; + + var buildMethodsForOperation = function(operation, method, path, resourceMap) { + var operationName = operation['operationId']; + var urlPath = path['x-path']; + + // Add the operation itself. + apiService[operationName] = function(opt_options, opt_parameters, opt_background, opt_forceget) { + var one = Restangular.one(buildUrl(urlPath, opt_parameters)); + if (opt_background) { + one.withHttpConfig({ + 'ignoreLoadingBar': true + }); + } + + var opObj = one[opt_forceget ? 'get' : 'custom' + method.toUpperCase()](opt_options); + + // If the operation requires_fresh_login, then add a specialized error handler that + // will defer the operation's result if sudo is requested. + if (operation['x-requires-fresh-login']) { + opObj = opObj.catch(freshLoginFailCheck(operationName, arguments)); + } + return opObj; + }; + + // If the method for the operation is a GET, add an operationAsResource method. + if (method == 'get') { + apiService[operationName + 'AsResource'] = function(opt_parameters, opt_background) { + var getMethod = apiService[operationName]; + return getResource(getMethod, operation, opt_parameters, opt_background); + }; + } + + // If the operation has a user-related operation, then make a generic operation for this operation + // that can call both the user and the organization versions of the operation, depending on the + // parameters given. + if (path['x-user-related']) { + var userOperationName = getMatchingUserOperationName(operationName, method, resourceMap[path['x-user-related']]); + var genericOperationName = getGenericOperationName(userOperationName); + apiService[genericOperationName] = function(orgname, opt_options, opt_parameters, opt_background) { + if (orgname) { + if (orgname.name) { + orgname = orgname.name; + } + + var params = jQuery.extend({'orgname' : orgname}, opt_parameters || {}, opt_background); + return apiService[operationName](opt_options, params); + } else { + return apiService[userOperationName](opt_options, opt_parameters, opt_background); + } + }; + } + }; + + + var allowedMethods = ['get', 'post', 'put', 'delete']; + var resourceMap = {}; + var forEachOperation = function(callback) { + for (var path in window.__endpoints) { + if (!window.__endpoints.hasOwnProperty(path)) { + continue; + } + + for (var method in window.__endpoints[path]) { + if (!window.__endpoints[path].hasOwnProperty(method)) { + continue; + } + + if (allowedMethods.indexOf(method.toLowerCase()) < 0) { continue; } + callback(window.__endpoints[path][method], method, window.__endpoints[path]); + } + } + }; + + // Build the map of resource names to their objects. + forEachOperation(function(operation, method, path) { + resourceMap[path['x-name']] = path; + }); + + // Construct the methods for each API endpoint. + forEachOperation(function(operation, method, path) { + buildMethodsForOperation(operation, method, path, resourceMap); + }); + + apiService.getErrorMessage = function(resp, defaultMessage) { + var message = defaultMessage; + if (resp && resp['data']) { + //TODO: remove error_message and error_description (old style error) + message = resp['data']['detail'] || resp['data']['error_message'] || resp['data']['message'] || resp['data']['error_description'] || message; + } + + return message; + }; + + apiService.errorDisplay = function(defaultMessage, opt_handler) { + return function(resp) { + var message = apiService.getErrorMessage(resp, defaultMessage); + if (opt_handler) { + var handlerMessage = opt_handler(resp); + if (handlerMessage) { + message = handlerMessage; + } + } + + message = UtilService.stringToHTML(message); + bootbox.dialog({ + "message": message, + "title": defaultMessage || 'Request Failure', + "buttons": { + "close": { + "label": "Close", + "className": "btn-primary" + } + } + }); + }; + }; + + // todo: remove hacks + apiService.scGetConfig = () => new Promise(() => { hello: true }); + apiService.scRegistryStatus = () => new Promise(() => { hello: true }); + + return apiService; +}]); diff --git a/config_app/js/services/container-service.js b/config_app/js/services/container-service.js new file mode 100644 index 000000000..c20cfc162 --- /dev/null +++ b/config_app/js/services/container-service.js @@ -0,0 +1,45 @@ +/** + * Helper service for working with the registry's container. Only works in enterprise. + */ +angular.module('quay-config') + .factory('ContainerService', ['ApiService', '$timeout', 'Restangular', + function(ApiService, $timeout, Restangular) { + var containerService = {}; + containerService.restartContainer = function(callback) { + ApiService.scShutdownContainer(null, null).then(function(resp) { + $timeout(callback, 2000); + }, ApiService.errorDisplay('Cannot restart container. Please report this to support.')) + }; + + containerService.scheduleStatusCheck = function(callback, opt_config) { + $timeout(function() { + containerService.checkStatus(callback, opt_config); + }, 2000); + }; + + containerService.checkStatus = function(callback, opt_config) { + var errorHandler = function(resp) { + if (resp.status == 404 || resp.status == 502 || resp.status == -1) { + // Container has not yet come back up, so we schedule another check. + containerService.scheduleStatusCheck(callback, opt_config); + return; + } + + return ApiService.errorDisplay('Cannot load status. Please report this to support')(resp); + }; + + // If config is specified, override the API base URL from this point onward. + // TODO(jschorr): Find a better way than this. This is safe, since this will only be called + // for a restart, but it is still ugly. + if (opt_config && opt_config['SERVER_HOSTNAME']) { + var scheme = opt_config['PREFERRED_URL_SCHEME'] || 'http'; + var baseUrl = scheme + '://' + opt_config['SERVER_HOSTNAME'] + '/api/v1/'; + Restangular.setBaseUrl(baseUrl); + } + + ApiService.scRegistryStatus(null, null, /* background */true) + .then(callback, errorHandler); + }; + + return containerService; + }]); diff --git a/config_app/js/services/cookie-service.js b/config_app/js/services/cookie-service.js new file mode 100644 index 000000000..af904124a --- /dev/null +++ b/config_app/js/services/cookie-service.js @@ -0,0 +1,23 @@ +/** + * Helper service for working with cookies. + */ +angular.module('quay-config').factory('CookieService', ['$cookies', function($cookies) { + var cookieService = {}; + cookieService.putPermanent = function(name, value) { + document.cookie = escape(name) + "=" + escape(value) + "; expires=Fri, 31 Dec 9999 23:59:59 GMT; path=/"; + }; + + cookieService.putSession = function(name, value) { + $cookies.put(name, value); + }; + + cookieService.clear = function(name) { + $cookies.remove(name); + }; + + cookieService.get = function(name) { + return $cookies.get(name); + }; + + return cookieService; +}]); diff --git a/config_app/js/services/features-config.js b/config_app/js/services/features-config.js new file mode 100644 index 000000000..e655f32bf --- /dev/null +++ b/config_app/js/services/features-config.js @@ -0,0 +1,91 @@ +/** + * Feature flags. + */ +angular.module('quay-config').factory('Features', [function() { + if (!window.__features) { + return {}; + } + + var features = window.__features; + features.getFeature = function(name, opt_defaultValue) { + var value = features[name]; + if (value == null) { + return opt_defaultValue; + } + return value; + }; + + features.hasFeature = function(name) { + return !!features.getFeature(name); + }; + + features.matchesFeatures = function(list) { + for (var i = 0; i < list.length; ++i) { + var value = features.getFeature(list[i]); + if (!value) { + return false; + } + } + return true; + }; + + return features; +}]); + +/** + * Application configuration. + */ +angular.module('quay-config').factory('Config', ['Features', function(Features) { + if (!window.__config) { + return {}; + } + + var config = window.__config; + config.getDomain = function() { + return config['SERVER_HOSTNAME']; + }; + + config.getHost = function(opt_auth) { + var auth = opt_auth; + if (auth) { + auth = auth + '@'; + } + + return config['PREFERRED_URL_SCHEME'] + '://' + auth + config['SERVER_HOSTNAME']; + }; + + config.getHttp = function() { + return config['PREFERRED_URL_SCHEME']; + }; + + config.getUrl = function(opt_path) { + var path = opt_path || ''; + return config['PREFERRED_URL_SCHEME'] + '://' + config['SERVER_HOSTNAME'] + path; + }; + + config.getValue = function(name, opt_defaultValue) { + var value = config[name]; + if (value == null) { + return opt_defaultValue; + } + return value; + }; + + config.getEnterpriseLogo = function(opt_defaultValue) { + if (!config.ENTERPRISE_LOGO_URL) { + if (opt_defaultValue) { + return opt_defaultValue; + } + + if (Features.BILLING) { + return '/static/img/quay-horizontal-color.svg'; + } else { + return '/static/img/QuayEnterprise_horizontal_color.svg'; + } + } + + return config.ENTERPRISE_LOGO_URL; + }; + + return config; +}]); \ No newline at end of file diff --git a/config_app/js/services/user-service.js b/config_app/js/services/user-service.js new file mode 100644 index 000000000..918ad9adb --- /dev/null +++ b/config_app/js/services/user-service.js @@ -0,0 +1,217 @@ +import * as Raven from 'raven-js'; + + +/** + * Service which monitors the current user session and provides methods for returning information + * about the user. + */ +angular.module('quay-config') + .factory('UserService', ['ApiService', 'CookieService', '$rootScope', 'Config', '$location', '$timeout', + +function(ApiService, CookieService, $rootScope, Config, $location, $timeout) { + var userResponse = { + verified: false, + anonymous: true, + username: null, + email: null, + organizations: [], + logins: [], + beforeload: true + }; + + var userService = {}; + var _EXTERNAL_SERVICES = ['ldap', 'jwtauthn', 'keystone', 'dex']; + + userService.hasEverLoggedIn = function() { + return CookieService.get('quay.loggedin') == 'true'; + }; + + userService.updateUserIn = function(scope, opt_callback) { + scope.$watch(function () { return userService.currentUser(); }, function (currentUser) { + if (currentUser) { + $timeout(function(){ + scope.user = currentUser; + if (opt_callback) { + opt_callback(currentUser); + } + }, 0, false); + }; + }, true); + }; + + userService.load = function(opt_callback) { + var handleUserResponse = function(loadedUser) { + userResponse = loadedUser; + + if (!userResponse.anonymous) { + if (Config.MIXPANEL_KEY) { + try { + mixpanel.identify(userResponse.username); + mixpanel.people.set({ + '$email': userResponse.email, + '$username': userResponse.username, + 'verified': userResponse.verified + }); + mixpanel.people.set_once({ + '$created': new Date() + }) + } catch (e) { + window.console.log(e); + } + } + + if (Config.MARKETO_MUNCHKIN_ID && userResponse['marketo_user_hash']) { + var associateLeadBody = {'Email': userResponse.email}; + if (window.Munchkin !== undefined) { + try { + Munchkin.munchkinFunction( + 'associateLead', + associateLeadBody, + userResponse['marketo_user_hash'] + ); + } catch (e) { + } + } else { + window.__quay_munchkin_queue.push([ + 'associateLead', + associateLeadBody, + userResponse['marketo_user_hash'] + ]); + } + } + + if (window.Raven !== undefined) { + try { + Raven.setUser({ + email: userResponse.email, + id: userResponse.username + }); + } catch (e) { + window.console.log(e); + } + } + + CookieService.putPermanent('quay.loggedin', 'true'); + } else { + if (window.Raven !== undefined) { + Raven.setUser(); + } + } + + // If the loaded user has a prompt, redirect them to the update page. + if (loadedUser.prompts && loadedUser.prompts.length) { + $location.path('/updateuser'); + return; + } + + if (opt_callback) { + opt_callback(loadedUser); + } + }; + + ApiService.getLoggedInUser().then(function(loadedUser) { + handleUserResponse(loadedUser); + }, function() { + handleUserResponse({'anonymous': true}); + }); + }; + + userService.isOrganization = function(name) { + return !!userService.getOrganization(name); + }; + + userService.getOrganization = function(name) { + if (!userResponse || !userResponse.organizations) { return null; } + for (var i = 0; i < userResponse.organizations.length; ++i) { + var org = userResponse.organizations[i]; + if (org.name == name) { + return org; + } + } + + return null; + }; + + userService.isNamespaceAdmin = function(namespace) { + if (namespace == userResponse.username) { + return true; + } + + var org = userService.getOrganization(namespace); + if (!org) { + return false; + } + + return org.is_org_admin; + }; + + userService.isKnownNamespace = function(namespace) { + if (namespace == userResponse.username) { + return true; + } + + var org = userService.getOrganization(namespace); + return !!org; + }; + + userService.getNamespace = function(namespace) { + var org = userService.getOrganization(namespace); + if (org) { + return org; + } + + if (namespace == userResponse.username) { + return userResponse; + } + + return null; + }; + + userService.getCLIUsername = function() { + if (!userResponse) { + return null; + } + + var externalUsername = null; + userResponse.logins.forEach(function(login) { + if (_EXTERNAL_SERVICES.indexOf(login.service) >= 0) { + externalUsername = login.service_identifier; + } + }); + + return externalUsername || userResponse.username; + }; + + userService.deleteNamespace = function(info, callback) { + var namespace = info.user ? info.user.username : info.organization.name; + if (!namespace) { + return; + } + + var errorDisplay = ApiService.errorDisplay('Could not delete namespace', callback); + var cb = function(resp) { + userService.load(function(currentUser) { + callback(true); + $location.path('/'); + }); + } + + if (info.user) { + ApiService.deleteCurrentUser().then(cb, errorDisplay) + } else { + var delParams = { + 'orgname': info.organization.name + }; + ApiService.deleteAdminedOrganization(null, delParams).then(cb, errorDisplay); + } + }; + + userService.currentUser = function() { + return userResponse; + }; + + // Update the user in the root scope. + userService.updateUserIn($rootScope); + + return userService; +}]); diff --git a/config_app/js/services/util-service.js b/config_app/js/services/util-service.js new file mode 100644 index 000000000..34f0a4191 --- /dev/null +++ b/config_app/js/services/util-service.js @@ -0,0 +1,83 @@ +/** + * Service which exposes various utility methods. + */ +angular.module('quay-config').factory('UtilService', ['$sanitize', + function($sanitize) { + var utilService = {}; + + var adBlockEnabled = null; + + utilService.isAdBlockEnabled = function(callback) { + if (adBlockEnabled !== null) { + callback(adBlockEnabled); + return; + } + + if(typeof blockAdBlock === 'undefined') { + callback(true); + return; + } + + var bab = new BlockAdBlock({ + checkOnLoad: false, + resetOnEnd: true + }); + + bab.onDetected(function() { adBlockEnabled = true; callback(true); }); + bab.onNotDetected(function() { adBlockEnabled = false; callback(false); }); + bab.check(); + }; + + utilService.isEmailAddress = function(val) { + var emailRegex = /^[a-zA-Z0-9.!#$%&’*+/=?^_`{|}~-]+@[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*$/; + return emailRegex.test(val); + }; + + utilService.escapeHtmlString = function(text) { + var textStr = (text || '').toString(); + var adjusted = textStr.replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); + + return adjusted; + }; + + utilService.stringToHTML = function(text) { + text = utilService.escapeHtmlString(text); + text = text.replace(/\n/g, '
'); + return text; + }; + + utilService.getRestUrl = function(args) { + var url = ''; + for (var i = 0; i < arguments.length; ++i) { + if (i > 0) { + url += '/'; + } + url += encodeURI(arguments[i]) + } + return url; + }; + + utilService.textToSafeHtml = function(text) { + return $sanitize(utilService.escapeHtmlString(text)); + }; + + return utilService; + }]) + .factory('CoreDialog', [() => { + var service = {}; + service['fatal'] = function(title, message) { + bootbox.dialog({ + "title": title, + "message": "
" + message, + "buttons": {}, + "className": "co-dialog fatal-error", + "closeButton": false + }); + }; + + return service; + }]); diff --git a/config_app/js/setup/setup.component.js b/config_app/js/setup/setup.component.js new file mode 100644 index 000000000..3e828214c --- /dev/null +++ b/config_app/js/setup/setup.component.js @@ -0,0 +1,332 @@ +import * as URI from 'urijs'; +const templateUrl = require('./setup.html'); + +(function() { + /** + * The Setup page provides a nice GUI walkthrough experience for setting up Quay Enterprise. + */ + + angular.module('quay-config').directive('setup', () => { + const directiveDefinitionObject = { + priority: 1, + templateUrl, + replace: true, + transclude: true, + restrict: 'C', + scope: { + 'isActive': '=isActive', + 'configurationSaved': '&configurationSaved' + }, + controller: SetupCtrl, + }; + + return directiveDefinitionObject; + }) + + function SetupCtrl($scope, $timeout, ApiService, Features, UserService, ContainerService, CoreDialog) { + // if (!Features.SUPER_USERS) { + // return; + // } + + $scope.HOSTNAME_REGEX = '^[a-zA-Z-0-9_\.\-]+(:[0-9]+)?$'; + + $scope.validateHostname = function(hostname) { + if (hostname.indexOf('127.0.0.1') == 0 || hostname.indexOf('localhost') == 0) { + return 'Please specify a non-localhost hostname. "localhost" will refer to the container, not your machine.' + } + + return null; + }; + + // Note: The values of the enumeration are important for isStepFamily. For example, + // *all* states under the "configuring db" family must start with "config-db". + $scope.States = { + // Loading the state of the product. + 'LOADING': 'loading', + + // The configuration directory is missing. + 'MISSING_CONFIG_DIR': 'missing-config-dir', + + // The config.yaml exists but it is invalid. + 'INVALID_CONFIG': 'config-invalid', + + // DB is being configured. + 'CONFIG_DB': 'config-db', + + // DB information is being validated. + 'VALIDATING_DB': 'config-db-validating', + + // DB information is being saved to the config. + 'SAVING_DB': 'config-db-saving', + + // A validation error occurred with the database. + 'DB_ERROR': 'config-db-error', + + // Database is being setup. + 'DB_SETUP': 'setup-db', + + // Database setup has succeeded. + 'DB_SETUP_SUCCESS': 'setup-db-success', + + // An error occurred when setting up the database. + 'DB_SETUP_ERROR': 'setup-db-error', + + // The container is being restarted for the database changes. + 'DB_RESTARTING': 'setup-db-restarting', + + // A superuser is being configured. + 'CREATE_SUPERUSER': 'create-superuser', + + // The superuser is being created. + 'CREATING_SUPERUSER': 'create-superuser-creating', + + // An error occurred when setting up the superuser. + 'SUPERUSER_ERROR': 'create-superuser-error', + + // The superuser was created successfully. + 'SUPERUSER_CREATED': 'create-superuser-created', + + // General configuration is being setup. + 'CONFIG': 'config', + + // The configuration is fully valid. + 'VALID_CONFIG': 'valid-config', + + // The container is being restarted for the configuration changes. + 'CONFIG_RESTARTING': 'config-restarting', + + // The product is ready for use. + 'READY': 'ready' + } + + $scope.csrf_token = window.__token; + $scope.currentStep = $scope.States.LOADING; + $scope.errors = {}; + $scope.stepProgress = []; + $scope.hasSSL = false; + $scope.hostname = null; + $scope.currentConfig = null; + + $scope.currentState = { + 'hasDatabaseSSLCert': false + }; + + $scope.$watch('currentStep', function(currentStep) { + $scope.stepProgress = $scope.getProgress(currentStep); + + switch (currentStep) { + case $scope.States.CONFIG: + $('#setupModal').modal('hide'); + break; + + case $scope.States.MISSING_CONFIG_DIR: + $scope.showMissingConfigDialog(); + break; + + case $scope.States.INVALID_CONFIG: + $scope.showInvalidConfigDialog(); + break; + + case $scope.States.DB_SETUP: + $scope.performDatabaseSetup(); + // Fall-through. + + case $scope.States.CREATE_SUPERUSER: + case $scope.States.DB_RESTARTING: + case $scope.States.CONFIG_DB: + case $scope.States.VALID_CONFIG: + case $scope.States.READY: + $('#setupModal').modal({ + keyboard: false, + backdrop: 'static' + }); + break; + } + }); + + $scope.restartContainer = function(state) { + $scope.currentStep = state; + ContainerService.restartContainer(function() { + $scope.checkStatus() + }); + }; + + $scope.showSuperuserPanel = function() { + $('#setupModal').modal('hide'); + var prefix = $scope.hasSSL ? 'https' : 'http'; + var hostname = $scope.hostname; + if (!hostname) { + hostname = document.location.hostname; + if (document.location.port) { + hostname = hostname + ':' + document.location.port; + } + } + + window.location = prefix + '://' + hostname + '/superuser'; + }; + + $scope.configurationSaved = function(config) { + $scope.hasSSL = config['PREFERRED_URL_SCHEME'] == 'https'; + $scope.hostname = config['SERVER_HOSTNAME']; + $scope.currentConfig = config; + + $scope.currentStep = $scope.States.VALID_CONFIG; + }; + + $scope.getProgress = function(step) { + var isStep = $scope.isStep; + var isStepFamily = $scope.isStepFamily; + var States = $scope.States; + + return [ + isStepFamily(step, States.CONFIG_DB), + isStepFamily(step, States.DB_SETUP), + isStep(step, States.DB_RESTARTING), + isStepFamily(step, States.CREATE_SUPERUSER), + isStep(step, States.CONFIG), + isStep(step, States.VALID_CONFIG), + isStep(step, States.CONFIG_RESTARTING), + isStep(step, States.READY) + ]; + }; + + $scope.isStepFamily = function(step, family) { + if (!step) { return false; } + return step.indexOf(family) == 0; + }; + + $scope.isStep = function(step) { + for (var i = 1; i < arguments.length; ++i) { + if (arguments[i] == step) { + return true; + } + } + return false; + }; + + $scope.beginSetup = function() { + $scope.currentStep = $scope.States.CONFIG_DB; + }; + + $scope.showInvalidConfigDialog = function() { + var message = "The config.yaml file found in conf/stack could not be parsed." + var title = "Invalid configuration file"; + CoreDialog.fatal(title, message); + }; + + + $scope.showMissingConfigDialog = function() { + var message = "A volume should be mounted into the container at /conf/stack: " + + "

docker run -v /path/to/config:/conf/stack
" + + "
Once fixed, restart the container. For more information, " + + "" + + "Read the Setup Guide" + + var title = "Missing configuration volume"; + CoreDialog.fatal(title, message); + }; + + $scope.parseDbUri = function(value) { + if (!value) { return null; } + + // Format: mysql+pymysql://:@/ + var uri = URI(value); + return { + 'kind': uri.protocol(), + 'username': uri.username(), + 'password': uri.password(), + 'server': uri.host(), + 'database': uri.path() ? uri.path().substr(1) : '' + }; + }; + + $scope.serializeDbUri = function(fields) { + if (!fields['server']) { return ''; } + if (!fields['database']) { return ''; } + + var uri = URI(); + try { + uri = uri && uri.host(fields['server']); + uri = uri && uri.protocol(fields['kind']); + uri = uri && uri.username(fields['username']); + uri = uri && uri.password(fields['password']); + uri = uri && uri.path('/' + (fields['database'] || '')); + uri = uri && uri.toString(); + } catch (ex) { + return ''; + } + + return uri; + }; + + $scope.createSuperUser = function() { + $scope.currentStep = $scope.States.CREATING_SUPERUSER; + ApiService.scCreateInitialSuperuser($scope.superUser, null).then(function(resp) { + UserService.load(); + $scope.checkStatus(); + }, function(resp) { + $scope.currentStep = $scope.States.SUPERUSER_ERROR; + $scope.errors.SuperuserCreationError = ApiService.getErrorMessage(resp, 'Could not create superuser'); + }); + }; + + $scope.performDatabaseSetup = function() { + $scope.currentStep = $scope.States.DB_SETUP; + ApiService.scSetupDatabase(null, null).then(function(resp) { + if (resp['error']) { + $scope.currentStep = $scope.States.DB_SETUP_ERROR; + $scope.errors.DatabaseSetupError = resp['error']; + } else { + $scope.currentStep = $scope.States.DB_SETUP_SUCCESS; + } + }, ApiService.errorDisplay('Could not setup database. Please report this to support.')) + }; + + $scope.validateDatabase = function() { + $scope.currentStep = $scope.States.VALIDATING_DB; + $scope.databaseInvalid = null; + + var data = { + 'config': { + 'DB_URI': $scope.databaseUri + }, + 'hostname': window.location.host + }; + + if ($scope.currentState.hasDatabaseSSLCert) { + data['config']['DB_CONNECTION_ARGS'] = { + 'ssl': { + 'ca': 'conf/stack/database.pem' + } + }; + } + + var params = { + 'service': 'database' + }; + + ApiService.scValidateConfig(data, params).then(function(resp) { + var status = resp.status; + + if (status) { + $scope.currentStep = $scope.States.SAVING_DB; + ApiService.scUpdateConfig(data, null).then(function(resp) { + $scope.checkStatus(); + }, ApiService.errorDisplay('Cannot update config. Please report this to support')); + } else { + $scope.currentStep = $scope.States.DB_ERROR; + $scope.errors.DatabaseValidationError = resp.reason; + } + }, ApiService.errorDisplay('Cannot validate database. Please report this to support')); + }; + + $scope.checkStatus = function() { + ContainerService.checkStatus(function(resp) { + $scope.currentStep = resp['status']; + }, $scope.currentConfig); + }; + + // Load the initial status. + $scope.checkStatus(); + }; +})(); diff --git a/config_app/js/setup/setup.html b/config_app/js/setup/setup.html new file mode 100644 index 000000000..b40e1d78d --- /dev/null +++ b/config_app/js/setup/setup.html @@ -0,0 +1,311 @@ +
+
+
+
+
+ + Quay Enterprise Setup +
+ +
+
+ + + + + + + + + + + +
Almost done!
+
Configure your Redis database and other settings below
+
+ + + +
+
+
+
+ + + +
diff --git a/config_app/static/css/core-ui.css b/config_app/static/css/core-ui.css new file mode 100644 index 000000000..2a7fdaf15 --- /dev/null +++ b/config_app/static/css/core-ui.css @@ -0,0 +1,1500 @@ +/* Global Brand Bar */ +.co-m-global-nav { + background: white; + height: 30px; + line-height: 36px; + position: relative; + z-index: 90; +} + +.co-m-global-nav svg { + width: auto !important; +} + +.co-m-global-nav .co-m-global-nav-left { + text-align: left; + padding-left: 28px; +} + +.co-m-global-nav .co-m-global-nav-right { + text-align: right; + font-size: 16px; + line-height: 30px; + padding-right: 25px; +} + +.co-m-global-nav .co-m-global-nav-item { + padding: 0 20px 0 15px; + border-right: 1px solid #eee; + display: inline-block; + height: 16px; + line-height: 16px; +} + +.co-m-global-nav .co-m-global-nav-item:first-of-type { + padding-left: 0; +} + +.co-m-global-nav .co-m-global-nav-item:last-of-type { + padding-right: 0; + border-right: 0; +} + +/* Tweaks for small screens */ +@media screen and (max-width: 767px) { + .co-m-global-nav { + display: none; /* hide the whole thing */ + } +} + +a:active { + outline: none !important; +} + +a:focus { + outline: none !important; +} + +.co-form-table label { + white-space: nowrap; +} + +.co-form-table td { + padding: 8px; +} + +.co-form-table td:first-child { + vertical-align: top; + padding-top: 14px; +} + +.co-form-table td .co-help-text { + margin-top: 10px; + margin-bottom: 4px; +} + +.co-help-text { + margin-top: 6px; + color: #aaa; + display: inline-block; +} + +.co-options-menu .fa-gear { + color: #999; + cursor: pointer; +} + +.co-options-menu .dropdown.open .fa-gear { + color: #428BCA; +} + +.co-img-bg-network { + background: url('/static/img/network-tile.png') left top repeat, linear-gradient(30deg, #2277ad, #144768) no-repeat left top fixed; + background-color: #2277ad; + background-size: auto, 100% 100%; +} + +.co-m-navbar { + background-color: white; + margin: 0; + padding-left: 10px; +} + +.co-fx-box-shadow { + -webkit-box-shadow: 0 2px 2px rgba(0, 0, 0, 0.2); + -moz-box-shadow: 0 2px 2px rgba(0, 0, 0, 0.2); + -ms-box-shadow: 0 2px 2px rgba(0, 0, 0, 0.2); + -o-box-shadow: 0 2px 2px rgba(0, 0, 0, 0.2); + box-shadow: 0 2px 2px rgba(0, 0, 0, 0.2); +} + +.co-fx-box-shadow-heavy { + -webkit-box-shadow: 0px 2px 2px rgba(0, 0, 0, 0.4); + -moz-box-shadow: 0px 2px 2px rgba(0, 0, 0, 0.4); + -ms-box-shadow: 0px 2px 2px rgba(0, 0, 0, 0.4); + -o-box-shadow: 0px 2px 2px rgba(0, 0, 0, 0.4); + box-shadow: 0px 2px 2px rgba(0, 0, 0, 0.4); +} + +.co-fx-text-shadow { + text-shadow: rgba(0, 0, 0, 1) 1px 1px 2px; +} + +.co-nav-title { + margin-top: -22px; + height: 70px; +} + +.co-nav-title .co-nav-title-content { + color: white; + text-align: center; + white-space: nowrap; +} + +.co-nav-title .co-nav-title-action { + display: block; + color: white; + text-align: center; + line-height: 70px; + font-size: 18px; +} + +.co-nav-title .co-nav-title-action a { + color: white; +} + +.co-nav-title .co-nav-title-action .fa { + margin-right: 6px; +} + + +@media (max-width: 767px) { + .co-nav-title { + height: auto; + min-height: 70px; + } + + .co-nav-title .co-nav-title-content { + height: 34px; + overflow: hidden; + text-overflow: ellipsis; + font-size: 22px; + } +} + +.co-main-content-panel { + margin-bottom: 20px; + background-color: #fff; + border: 1px solid transparent; + padding: 10px; + + -webkit-box-shadow: 0px 2px 2px rgba(0, 0, 0, 0.4); + -moz-box-shadow: 0px 2px 2px rgba(0, 0, 0, 0.4); + -ms-box-shadow: 0px 2px 2px rgba(0, 0, 0, 0.4); + -o-box-shadow: 0px 2px 2px rgba(0, 0, 0, 0.4); + box-shadow: 0px 2px 2px rgba(0, 0, 0, 0.4); +} + +.cor-log-box { + width: 100%; + height: 550px; + position: relative; +} + +.co-log-viewer { + position: absolute; + top: 20px; + left: 20px; + right: 20px; + height: 500px; + + padding: 20px; + + background: rgb(55, 55, 55); + border: 1px solid black; + color: white; + + overflow: scroll; +} + +.co-log-viewer .co-log-content { + font-family: Consolas, "Lucida Console", Monaco, monospace; + font-size: 12px; + white-space: pre; +} + +.cor-log-box .co-log-viewer-new-logs i { + margin-left: 10px; + display: inline-block; +} + +.cor-log-box .co-log-viewer-new-logs { + cursor: pointer; + position: absolute; + bottom: 40px; + right: 30px; + padding: 10px; + color: white; + border-radius: 10px; + background: rgba(72, 158, 72, 0.8); +} + +.co-panel { + margin-bottom: 40px; + + /*border: 1px solid #eee;*/ +} + +.co-panel .co-panel-heading img { + margin-right: 6px; + width: 24px; +} + +.co-panel .co-panel-heading > i.fa { + margin-right: 6px; + width: 24px; + text-align: center; +} + +.co-panel .co-panel-heading { + padding: 6px; + /*background: #eee;*/ + border-bottom: 1px solid #eee; + + margin-bottom: 4px; + font-size: 135%; + padding-left: 10px; +} + +.co-panel .co-panel-body { + padding: 10px; +} + +@media (max-width: 767px) { + .co-panel > .co-panel-body { + padding: 0px; + padding-top: 10px; + padding-bottom: 10px; + } + + .co-panel > .panel-body { + padding: 0px; + padding-top: 10px; + padding-bottom: 10px; + } +} + + +.co-panel .co-panel-button-bar { + margin-top: 10px; + padding-top: 10px; + border-top: 1px solid #eee; +} + +.co-panel-body .co-panel-heading { + font-size: 120%; + border-bottom: 0px; + margin: 0px; + margin-bottom: -6px; +} + +.co-panel-body .co-panel-body { + padding-left: 38px; +} + + +.config-bool-field-element input { + margin-right: 6px; + font-size: 24px; +} + +.config-setup-tool-element .help-text { + margin-top: 6px; + color: #aaa; +} + +.config-setup-tool-element .description { + padding: 6px; +} + +.config-setup-tool-element .config-table > tbody > tr > td:first-child { + padding-top: 14px; + font-weight: bold; +} + +.config-setup-tool-element .config-table > tbody > tr > td.non-input { + padding-top: 8px; +} + +.config-setup-tool-element .config-table > tbody > tr > td { + padding: 8px; + vertical-align: top; +} + +.config-setup-tool-element .config-table > tbody > tr > td .config-numeric-field-element { + width: 100px; +} + +.config-setup-tool-element .config-table > tbody > tr > td .config-string-field-element { + width: 400px; +} + +.config-setup-tool-element .config-table > tbody > tr > td .config-string-list-field-element { + width: 400px; +} + +.config-map-field-element table { + margin-bottom: 10px; +} + +.config-map-field-element .form-control-container { + border-top: 1px solid #eee; + padding-top: 10px; +} + +.config-map-field-element .form-control-container select, .config-map-field-element .form-control-container input { + margin-bottom: 10px; +} + +.config-map-field-element .empty { + color: #ccc; + margin-bottom: 10px; + display: block; +} + +.config-map-field-element .item-title { + font-weight: bold; +} + +.config-contact-field { + margin-bottom: 4px; +} + +.config-contact-field .dropdown button { + width: 100px; + text-align: left; +} + +.config-contact-field .dropdown button .caret { + float: right; + margin-top: 9px; +} + +.config-contact-field .dropdown button i.fa { + margin-right: 6px; + width: 14px; + text-align: center; + display: inline-block; +} + +.config-contact-field .form-control { + width: 350px; +} + +.config-certificates-field-element .dns-name { + display: inline-block; + margin-right: 10px; +} + +.config-certificates-field-element .cert-status .fa { + margin-right: 4px; +} + +.config-certificates-field-element .cert-status .green { + color: #2FC98E; +} + +.config-certificates-field-element .cert-status .orange { + color: #FCA657; +} + +.config-certificates-field-element .cert-status .red { + color: #D64456; +} + +.config-certificates-field-element .file-upload-box-element .file-input-container { + padding: 0px; + text-align: left; +} + +.config-certificates-field-element .file-upload-box-element .file-drop + label { + margin-top: 0px; + margin-bottom: 4px; +} + +.config-list-field-element .empty { + color: #ccc; + margin-bottom: 10px; + display: block; +} + +.config-list-field-element input { + vertical-align: middle; +} + +.config-list-field-element .item-delete { + display: inline-block; + margin-left: 20px; +} + +.config-list-field-element input { + width: 350px; +} + +.config-setup-tool-element .inner-table { + margin-left: 10px; +} + +.config-setup-tool-element .inner-table tr td:first-child { + font-weight: bold; +} + +.config-setup-tool-element .inner-table td { + padding: 6px; +} + +.config-file-field-element input { + display: inline-block; + margin-left: 10px; +} + +.config-service-key-field-element { + position: relative; +} + +.config-service-key-field-element .co-modify-link { + margin-left: 10px; +} + +.config-service-key-field-element .fa-check { + margin-right: 4px; +} + +.co-checkbox { + position: relative; +} + +.co-checkbox input { + display: none; +} + +.co-checkbox label { + position: relative; + padding-left: 28px; + cursor: pointer; +} + +.co-checkbox label:before { + content: ''; + cursor: pointer; + position: absolute; + width: 20px; + height: 20px; + top: 0; + left: 0; + border-radius: 4px; + + -webkit-box-shadow: inset 0px 1px 1px rgba(0,0,0,0.5), 0px 1px 0px rgba(255,255,255,.4); + -moz-box-shadow: inset 0px 1px 1px rgba(0,0,0,0.5), 0px 1px 0px rgba(255,255,255,.4); + box-shadow: inset 0px 1px 1px rgba(0,0,0,0.5), 0px 1px 0px rgba(255,255,255,.4); + + background: -webkit-linear-gradient(top, #222 0%, #45484d 100%); + background: -moz-linear-gradient(top, #222 0%, #45484d 100%); + background: -o-linear-gradient(top, #222 0%, #45484d 100%); + background: -ms-linear-gradient(top, #222 0%, #45484d 100%); + background: linear-gradient(top, #222 0%, #45484d 100%); + filter: progid:DXImageTransform.Microsoft.gradient( startColorstr='#222', endColorstr='#45484d',GradientType=0 ); +} + +.co-checkbox label:after { + -ms-filter: "progid:DXImageTransform.Microsoft.Alpha(Opacity=0)"; + filter: alpha(opacity=0); + opacity: 0; + content: ''; + position: absolute; + width: 11px; + height: 7px; + background: transparent; + top: 5px; + left: 4px; + border: 3px solid #fcfff4; + border-top: none; + border-right: none; + + -webkit-transform: rotate(-45deg); + -moz-transform: rotate(-45deg); + -o-transform: rotate(-45deg); + -ms-transform: rotate(-45deg); + transform: rotate(-45deg); +} + +.co-checkbox label:hover::after { + -ms-filter: "progid:DXImageTransform.Microsoft.Alpha(Opacity=30)"; + filter: alpha(opacity=30); + opacity: 0.3; +} + +.co-checkbox input[type=checkbox]:checked + label:after { + -ms-filter: "progid:DXImageTransform.Microsoft.Alpha(Opacity=100)"; + filter: alpha(opacity=100); + opacity: 1; + border: 3px solid rgb(26, 255, 26); + border-top: none; + border-right: none; +} + +.co-floating-bottom-bar { + height: 50px; +} + +.co-floating-bottom-bar.floating { + position: fixed; + bottom: 0px; +} + +.config-setup-tool .cor-floating-bottom-bar button i.fa { + margin-right: 6px; +} + +.config-setup-tool .service-verification { + padding: 20px; + background: #343434; + color: white; + margin-bottom: -14px; +} + +.config-setup-tool .service-verification-row { + margin-bottom: 6px; +} + +.config-setup-tool .service-verification-row .service-title { + font-variant: small-caps; + font-size: 145%; + vertical-align: middle; +} + +#validateAndSaveModal .fa-warning { + font-size: 22px; + margin-right: 10px; + vertical-align: middle; + color: rgb(255, 186, 53); +} + +#validateAndSaveModal .fa-check-circle { + font-size: 22px; + margin-right: 10px; + vertical-align: middle; + color: rgb(53, 186, 53); +} + +.config-setup-tool .service-verification-error { + white-space: pre-wrap; + margin-top: 10px; + margin-left: 36px; + margin-bottom: 20px; + max-height: 250px; + overflow: auto; + border: 1px solid #797979; + background: black; + padding: 6px; + font-family: Consolas, "Lucida Console", Monaco, monospace; + font-size: 12px; +} + +.co-m-loader, .co-m-inline-loader { + min-width: 28px; } + +.co-m-loader { + display: block; + position: absolute; + left: 50%; + top: 50%; + margin: -11px 0 0 -13px; } + +.co-m-inline-loader { + display: inline-block; + cursor: default; } + .co-m-inline-loader:hover { + text-decoration: none; } + +.co-m-loader-dot__one, .co-m-loader-dot__two, .co-m-loader-dot__three { + -webkit-border-radius: 3px; + -moz-border-radius: 3px; + -ms-border-radius: 3px; + -o-border-radius: 3px; + border-radius: 3px; + animation-fill-mode: both; + -webkit-animation-fill-mode: both; + -moz-animation-fill-mode: both; + -ms-animation-fill-mode: both; + -o-animation-fill-mode: both; + animation-name: bouncedelay; + animation-duration: 1s; + animation-timing-function: ease-in-out; + animation-delay: 0; + animation-direction: normal; + animation-iteration-count: infinite; + animation-fill-mode: forwards; + animation-play-state: running; + -webkit-animation-name: bouncedelay; + -webkit-animation-duration: 1s; + -webkit-animation-timing-function: ease-in-out; + -webkit-animation-delay: 0; + -webkit-animation-direction: normal; + -webkit-animation-iteration-count: infinite; + -webkit-animation-fill-mode: forwards; + -webkit-animation-play-state: running; + -moz-animation-name: bouncedelay; + -moz-animation-duration: 1s; + -moz-animation-timing-function: ease-in-out; + -moz-animation-delay: 0; + -moz-animation-direction: normal; + -moz-animation-iteration-count: infinite; + -moz-animation-fill-mode: forwards; + -moz-animation-play-state: running; + display: inline-block; + height: 6px; + width: 6px; + background: #419eda; + border-radius: 100%; + display: inline-block; } + +.co-m-loader-dot__one { + animation-delay: -0.32s; + -webkit-animation-delay: -0.32s; + -moz-animation-delay: -0.32s; + -ms-animation-delay: -0.32s; + -o-animation-delay: -0.32s; } + +.co-m-loader-dot__two { + animation-delay: -0.16s; + -webkit-animation-delay: -0.16s; + -moz-animation-delay: -0.16s; + -ms-animation-delay: -0.16s; + -o-animation-delay: -0.16s; } + +@-webkit-keyframes bouncedelay { + 0%, 80%, 100% { + -webkit-transform: scale(0.25, 0.25); + -moz-transform: scale(0.25, 0.25); + -ms-transform: scale(0.25, 0.25); + -o-transform: scale(0.25, 0.25); + transform: scale(0.25, 0.25); } + + 40% { + -webkit-transform: scale(1, 1); + -moz-transform: scale(1, 1); + -ms-transform: scale(1, 1); + -o-transform: scale(1, 1); + transform: scale(1, 1); } } + +@-moz-keyframes bouncedelay { + 0%, 80%, 100% { + -webkit-transform: scale(0.25, 0.25); + -moz-transform: scale(0.25, 0.25); + -ms-transform: scale(0.25, 0.25); + -o-transform: scale(0.25, 0.25); + transform: scale(0.25, 0.25); } + + 40% { + -webkit-transform: scale(1, 1); + -moz-transform: scale(1, 1); + -ms-transform: scale(1, 1); + -o-transform: scale(1, 1); + transform: scale(1, 1); } } + +@-ms-keyframes bouncedelay { + 0%, 80%, 100% { + -webkit-transform: scale(0.25, 0.25); + -moz-transform: scale(0.25, 0.25); + -ms-transform: scale(0.25, 0.25); + -o-transform: scale(0.25, 0.25); + transform: scale(0.25, 0.25); } + + 40% { + -webkit-transform: scale(1, 1); + -moz-transform: scale(1, 1); + -ms-transform: scale(1, 1); + -o-transform: scale(1, 1); + transform: scale(1, 1); } } + +@keyframes bouncedelay { + 0%, 80%, 100% { + -webkit-transform: scale(0.25, 0.25); + -moz-transform: scale(0.25, 0.25); + -ms-transform: scale(0.25, 0.25); + -o-transform: scale(0.25, 0.25); + transform: scale(0.25, 0.25); } + + 40% { + -webkit-transform: scale(1, 1); + -moz-transform: scale(1, 1); + -ms-transform: scale(1, 1); + -o-transform: scale(1, 1); + transform: scale(1, 1); } } + +.co-dialog .modal-body { + padding: 10px; + min-height: 100px; +} + +.co-dialog .modal-body h4 { + margin-bottom: 20px; +} + +.co-dialog .modal-content { + border-radius: 0px; +} + +.co-dialog.fatal-error .modal-content { + padding-left: 175px; +} + +.co-dialog.fatal-error .alert-icon-container-container { + position: absolute; + top: -36px; + left: -175px; + bottom: 20px; +} + +.co-dialog.fatal-error .alert-icon-container { + height: 100%; + display: table; +} + +.co-dialog.fatal-error .alert-icon { + display: table-cell; + vertical-align: middle; + border-right: 1px solid #eee; + margin-right: 20px; +} + +.co-dialog.fatal-error .alert-icon:before { + content: "\f071"; + font-family: FontAwesome; + font-size: 60px; + padding-left: 50px; + padding-right: 50px; + color: #c53c3f; + text-align: center; +} + + +.co-dialog .modal-header .cor-step-bar { + float: right; +} + +.co-dialog .modal-footer.working { + text-align: left; +} + +.co-dialog .modal-footer.working .btn { + float: right; +} + +.co-dialog .modal-footer.working .cor-loader-inline { + margin-right: 10px; +} + +.co-dialog .modal-footer .left-align { + float: left; + vertical-align: middle; + font-size: 16px; + margin-top: 8px; +} + +.co-dialog .modal-footer .left-align i.fa-warning { + color: #ffba35; + display: inline-block; + margin-right: 6px; +} + +.co-dialog .modal-footer .left-align i.fa-check { + color: green; + display: inline-block; + margin-right: 6px; +} + +.co-dialog .co-single-field-dialog { + padding: 10px; +} + +.co-dialog .co-single-field-dialog input { + margin-top: 10px; +} + +.co-step-bar .co-step-element { + cursor: default; + display: inline-block; + width: 28px; + height: 28px; + + position: relative; + color: #ddd; + + text-align: center; + line-height: 24px; + font-size: 16px; +} + +.co-step-bar .co-step-element.text { + margin-left: 24px; + background: white; +} + +.co-step-bar .co-step-element.icon { + margin-left: 22px; +} + +.co-step-bar .co-step-element:first-child { + margin-left: 0px; +} + +.co-step-bar .co-step-element.active { + color: #53a3d9; +} + +.co-step-bar .co-step-element:first-child:before { + display: none; +} + +.co-step-bar .co-step-element:before { + content: ""; + position: absolute; + top: 12px; + width: 14px; + border-top: 2px solid #ddd; +} + +.co-step-bar .co-step-element.icon:before { + left: -20px; +} + +.co-step-bar .co-step-element.text:before { + left: -22px; +} + +.co-step-bar .co-step-element.active:before { + border-top: 2px solid #53a3d9; +} + + +.co-step-bar .co-step-element.text { + border-radius: 100%; + border: 2px solid #ddd; +} + +.co-step-bar .co-step-element.text.active { + border: 2px solid #53a3d9; +} + +@media screen and (min-width: 900px) { + .co-dialog .modal-dialog { + width: 800px; + } +} + +@media screen and (min-width: 1200px) { + .co-dialog.wider .modal-dialog { + width: 1000px; + } +} + +.co-alert .co-step-bar { + float: right; + margin-top: 6px; +} + +.cor-container { + padding-left: 15px; + padding-right: 15px; +} + +.cor-title-link { + font-weight: 300; + line-height: 30px; + margin-top: 22px; + margin-bottom: 10px; + font-size: 16px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + color: white; +} + +.cor-title-link a { + color: white; + text-decoration: none !important; +} + +.cor-title-link a.back-link .avatar { + margin-right: 6px; +} + +.cor-title-link a.back-link:before { + content: "\f060"; + color: white; + display: inline-block; + margin-right: 10px; + vertical-align: middle; + font-family: FontAwesome; +} + +.co-table { + width: 100%; +} + +.co-fixed-table { + table-layout: fixed; +} + +.co-fixed-table .co-flowing-col { + overflow: hidden; + text-overflow: ellipsis; + padding-left: 16px; + vertical-align: middle; +} + +.co-fixed-table .nowrap-col { + white-space: nowrap; + overflow: hidden; +} + +.co-table td { + border-bottom: 1px solid #eee; + padding: 10px; +} + +.co-table.no-lines td { + border-bottom: 0px; + padding: 6px; +} + +.co-table thead td { + color: #999; + font-size: 90%; + text-transform: uppercase; + font-weight: 300; + padding-top: 0px !important; +} + +.co-table thead td a { + color: #666; +} + +.co-table thead td:after { + content: "\f175"; + font-family: FontAwesome; + font-size: 12px; + margin-left: 10px; + visibility: hidden; +} + +.co-table thead td.unorderable-col:after { + display: none; +} + +.co-table thead td.current:after { + content: "\f175"; + visibility: visible; +} + +.co-table thead td.current.reversed:after { + content: "\f176"; + visibility: visible; +} + +.co-table thead td.current a { + color: #337ab7; +} + +.co-table .checkbox-col { + width: 24px; + text-align: center; +} + +.co-table .checkbox-col .co-checkable-menu a { + color: black; + text-transform: none; +} + +.co-table thead td.checkbox-menu-col:after { + display: none; +} + +.co-table .offset-check-col { + padding-left: 22px; +} + +.co-table td.options-col { + width: 36px; + text-align: center; +} + +.co-table td.caret-col { + width: 10px; + padding-left: 6px; + padding-right: 0px; + color: #aaa; + text-align: center; + max-width: 20px; +} + +.co-table td.caret-col i.fa { + cursor: pointer; +} + +.co-table td.caret-col i.fa.fa-caret-down { + color: black; +} + +.co-table .add-row-spacer td { + padding: 5px; +} + +.co-table .add-row td { + padding-top: 10px; + border-top: 2px solid #eee; + border-bottom: none; +} + +.co-table tr.co-table-header-row td { + font-size: 12px; + text-transform: uppercase; + color: #ccc; + border-bottom: none; + padding-left: 10px; + padding-top: 10px; + padding-bottom: 4px; +} + +.co-table tr.co-table-header-row td i.fa { + margin-right: 4px; +} + +.co-table tr.indented-row td:first-child { + padding-left: 28px; +} + +@media (max-width: 767px) { + .co-table tr.indented-row td:first-child { + padding-left: 10px; + } +} + +.co-table .mobile-row { + border-bottom: 2px solid #eee; + padding-bottom: 10px; + margin-bottom: 10px; + + position: relative; +} + +.co-table .mobile-row:last-child { + border-bottom: 0px solid #eee; + padding-bottom: 0px; + margin-bottom: 0px; +} + +.co-table .mobile-row .mobile-col-header { + font-weight: bold; + color: #444; +} + +.co-table .mobile-row .mobile-col-value { + padding: 6px; +} + +.co-table .mobile-row .options-col { + position: absolute; + top: -6px; + right: 0px; +} + + +.cor-checkable-menu { + display: inline-block; +} + +.co-checkable-menu .co-checkable-menu-state { + display: inline-block; + margin-left: -1px; + margin-right: 4px; +} + +.co-checkable-menu .dropdown { + display: inline-block; +} + +.co-checkable-item, .co-checkable-menu-state { + width: 18px; + height: 18px; + cursor: pointer; + border: 1px solid #ddd; + display: inline-block; + vertical-align: middle; + + position: relative +} + +.co-checkable-item:after, .co-checkable-menu-state:after { + content: "\f00c"; + font-family: FontAwesome; + color: #ccc; + + position: absolute; + top: -1px; + left: 1px; + + visibility: hidden; +} + +.co-checkable-menu-state.some:after { + content: "-"; + font-size: 24px; + top: -10px; + left: 4px; +} + +@media (min-width: 768px) { + .co-checkable-item:hover:after { + visibility: visible; + } +} + +.co-checkable-item.checked:after, .co-checkable-menu-state.all:after, .co-checkable-menu-state.some:after { + visibility: visible; + color: #428bca; +} + +.co-table .co-checkable-row.checked { + background: #F6FCFF; +} + +.co-filter-box { + position: relative;; +} + +.co-filter-box input { + display: inline-block; + width: auto !important; +} + +.co-filter-box .filter-message { + display: inline-block; + position: absolute; + left: -220px; + top: 7px; + color: #ccc; +} + +.co-filter-box .filter-options { + margin-top: 4px; + font-size: 14px; + text-align: right; + display: inline-block; +} + +.co-filter-box .filter-options label input { + margin-right: 4px; +} + + +.co-filter-box.with-options > input { + display: inline-block; + width: 200px; + margin-right: 4px; +} + +.co-check-bar { + margin-bottom: 10px; +} + +.co-check-bar .co-checked-actions { + display: inline-block; + border-left: 1px solid #eee; + margin-left: 10px; + padding-left: 4px; +} + +.co-top-bar { + height: 50px; + padding-bottom: 40px; +} + +.co-check-bar .co-checked-actions .btn { + margin-left: 6px; +} + +.co-check-bar .co-checked-actions .btn .fa { + margin-right: 4px; +} + +.co-check-bar .co-filter-box, .co-top-bar .co-filter-box { + float: right; +} + +.co-check-bar .co-filter-box .page-controls, .co-top-bar .co-filter-box .page-controls { + margin-right: 6px; + margin-bottom: 6px; +} + +.co-check-bar .co-filter-box input, .co-top-bar .co-filter-box input[type="text"] { + width: 300px; + display: inline-block; + vertical-align: middle; +} + +.co-check-bar .co-filter-box input, .co-top-bar .co-filter-box label { + margin-left: 6px; +} + +.co-top-bar .co-filter-box input { + vertical-align: top; +} + +@media screen and (max-width: 640px) { + .co-top-bar .page-controls { + margin-bottom: 10px; + text-align: right; + } + + .co-top-bar .co-filter-box { + display: block; + margin-bottom: 10px; + } + + .co-top-bar .filter-options { + display: block; + margin-bottom: 10px; + } + + .co-filter-box input { + display: block !important; + } +} + +.empty { + border-bottom: none !important; +} + +.empty-icon { + color: #aaa; + font-size: 60px; + margin-bottom: 0px; + text-align: center; +} + +.empty-primary-msg { + font-size: 18px; + margin-bottom: 10px; + text-align: center; +} + +.empty-secondary-msg { + font-size: 14px; + color: #999; + text-align: center; + margin-bottom: 10px; +} + +.co-alert { + padding: 16px; + padding-left: 46px; + position: relative; + margin-bottom: 20px; + position: relative; + border: 1px solid #eee; +} + +.co-alert.co-alert-success { + background: #F0FFF4; +} + +.co-alert.co-alert-success:before { + font-family: FontAwesome; + content: "\f058"; + position: absolute; + top: 11px; + left: 12px; + font-size: 22px; + color: #83D29C; +} + +.co-alert.co-alert-info { + background: #F0FAFF; +} + +.co-alert.co-alert-info:before { + font-family: FontAwesome; + content: "\f05a"; + position: absolute; + top: 11px; + left: 12px; + font-size: 22px; + color: #83B7D2; +} + +.co-alert.co-alert-warning { + background: #FFFBF0; +} + +.co-alert.co-alert-warning:before { + font-family: FontAwesome; + content: "\f071"; + position: absolute; + top: 11px; + left: 12px; + font-size: 22px; + color: #FCA657; +} + +.co-alert.co-alert-danger { + background: #FFF0F0; +} + +.co-alert.co-alert-danger:before { + font-family: core-icons; + content: "\f109"; + position: absolute; + top: 11px; + left: 12px; + font-size: 22px; + color: red; +} + +.co-alert.co-alert-danger:after { + font-family: FontAwesome; + content: "\f12a"; + position: absolute; + top: 16px; + left: 20px; + font-size: 16px; + color: white; + z-index: 2; +} + +.co-alert.thin { + padding: 6px; + padding-left: 38px; + margin-bottom: 0px; +} + +.co-alert.thin:before { + top: 5px; + font-size: 18px; +} + +.co-alert.thin:after { + top: 9px; + font-size: 13px; + left: 19px; +} + +.co-alert-inline:before { + position: relative !important; + top: auto !important; + left: auto !important; + vertical-align: middle; + margin-right: 10px; +} + +.co-alert-popin-warning { + margin-left: 10px; +} + +@media screen and (max-width: 767px) { + .co-alert-popin-warning { + display: block; + margin: 0px; + margin-top: 10px; + float: none; + } +} + +.co-alert-inline { + border: 0px; + display: inline-block; + background-color: transparent !important; + margin: 0px; + padding: 4px; +} + +.co-list-table tr td:first-child { + font-weight: bold; + padding-right: 10px; + vertical-align: top; + width: 120px; + padding-left: 0px; +} + +.co-list-table tr td { + padding: 10px; + font-size: 15px; +} + +.co-list-table .help-text { + margin-top: 6px; + font-size: 14px; + color: #aaa; +} + +.co-modify-link:after { + font-family: FontAwesome; + content: "\f054"; + color: #ccc; + vertical-align: middle; + display: inline-block; + margin-left: 10px; + font-size: 10px; + line-height: 16px; +} + +.co-option-table tr td:first-child { + padding-left: 16px; + padding-right: 16px; + padding-top: 0px; + vertical-align: top; +} + +.co-option-table tr td:last-child { + padding-bottom: 10px; +} + +.co-option-table .help-text { + margin-top: 4px; + margin-bottom: 10px; + font-size: 14px; + color: #aaa; +} + +.co-modal-body-scrollable { + overflow-y: auto; + overflow-x: hidden; + max-height: 400px; +} + +.cor-confirm-dialog-element .modal-body { + padding: 20px; +} + +.cor-confirm-dialog-element .progress-message { + margin-bottom: 10px; + font-size: 16px; +} + +.co-top-tab-bar { + padding: 0px; + margin: 0px; + padding-left: 10px; + + margin-bottom: 10px; + border-bottom: 1px solid #eee; +} + +.co-top-tab-bar li { + display: inline-block; + list-style: none; + text-align: center; + padding: 6px; + padding-left: 10px; + padding-right: 10px; + border-bottom: 1px solid #eee; + font-size: 15px; + cursor: pointer; + color: #666; + + bottom: -2px; + position: relative; +} + +.co-top-tab-bar li.active { + color: #51a3d9; + border-bottom: 2px solid #51a3d9; + top: 2px; +} + +.modal-header.ahead-of-tabs { + border-bottom: 0px; + padding-bottom: 4px; +} diff --git a/config_app/static/lib/angular-file-upload.min.js b/config_app/static/lib/angular-file-upload.min.js new file mode 100644 index 000000000..b9d0196f7 --- /dev/null +++ b/config_app/static/lib/angular-file-upload.min.js @@ -0,0 +1,2 @@ +/*! 1.4.0 */ +!function(){var a=angular.module("angularFileUpload",[]);a.service("$upload",["$http","$timeout",function(a,b){function c(c){c.method=c.method||"POST",c.headers=c.headers||{},c.transformRequest=c.transformRequest||function(b,c){return window.ArrayBuffer&&b instanceof window.ArrayBuffer?b:a.defaults.transformRequest[0](b,c)},window.XMLHttpRequest.__isShim&&(c.headers.__setXHR_=function(){return function(a){a&&(c.__XHR=a,c.xhrFn&&c.xhrFn(a),a.upload.addEventListener("progress",function(a){c.progress&&b(function(){c.progress&&c.progress(a)})},!1),a.upload.addEventListener("load",function(a){a.lengthComputable&&c.progress&&c.progress(a)},!1))}});var d=a(c);return d.progress=function(a){return c.progress=a,d},d.abort=function(){return c.__XHR&&b(function(){c.__XHR.abort()}),d},d.xhr=function(a){return c.xhrFn=a,d},d.then=function(a,b){return function(d,e,f){c.progress=f||c.progress;var g=b.apply(a,[d,e,f]);return g.abort=a.abort,g.progress=a.progress,g.xhr=a.xhr,g.then=a.then,g}}(d,d.then),d}this.upload=function(b){b.headers=b.headers||{},b.headers["Content-Type"]=void 0,b.transformRequest=b.transformRequest||a.defaults.transformRequest;var d=new FormData,e=b.transformRequest,f=b.data;return b.transformRequest=function(a,c){if(f)if(b.formDataAppender)for(var d in f){var g=f[d];b.formDataAppender(a,d,g)}else for(var d in f){var g=f[d];if("function"==typeof e)g=e(g,c);else for(var h=0;h0||navigator.msMaxTouchPoints>0)&&d.bind("touchend",function(a){a.preventDefault(),a.target.click()})}}]),a.directive("ngFileDropAvailable",["$parse","$timeout",function(a,b){return function(c,d,e){if("draggable"in document.createElement("span")){var f=a(e.ngFileDropAvailable);b(function(){f(c)})}}}]),a.directive("ngFileDrop",["$parse","$timeout",function(a,b){return function(c,d,e){function f(a,b){if(b.isDirectory){var c=b.createReader();i++,c.readEntries(function(b){for(var c=0;c0&&j[0].webkitGetAsEntry)for(var k=0;k - - - Config app - - -
-

What is my purpose

-

You make tarballs

-
- - diff --git a/config_app/templates/index.html b/config_app/templates/index.html new file mode 100644 index 000000000..7a40e3d30 --- /dev/null +++ b/config_app/templates/index.html @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {% for script_path in main_scripts %} + + {% endfor %} + Config app + + +
+

What is my purpose

+

You make tarballs

+
+ +
+
+
+ + diff --git a/config_app/util/__init__.py b/config_app/util/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/config_app/util/baseprovider.py b/config_app/util/baseprovider.py new file mode 100644 index 000000000..5a616895f --- /dev/null +++ b/config_app/util/baseprovider.py @@ -0,0 +1,128 @@ +import logging +import yaml + +from abc import ABCMeta, abstractmethod +from six import add_metaclass + +from jsonschema import validate, ValidationError + +from util.config.schema import CONFIG_SCHEMA + +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) + if not c: + logger.debug('Empty YAML config file') + return + + if isinstance(c, str): + raise Exception('Invalid YAML config file: ' + str(c)) + + for key in c.iterkeys(): + if key.isupper(): + config_obj[key] = c[key] + + if config_obj.get('SETUP_COMPLETE', True): + try: + validate(config_obj, CONFIG_SCHEMA) + except ValidationError: + # TODO: Change this into a real error + logger.exception('Could not validate config schema') + else: + logger.debug('Skipping config schema validation because setup is not complete') + + return config_obj + + +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: + f.write(get_yaml(config_obj)) + except IOError as ioe: + raise CannotWriteConfigException(str(ioe)) + + +@add_metaclass(ABCMeta) +class BaseProvider(object): + """ A configuration provider helps to load, save, and handle config override in the application. + """ + + @property + def provider_id(self): + raise NotImplementedError + + @abstractmethod + def update_app_config(self, app_config): + """ Updates the given application config object with the loaded override config. """ + + @abstractmethod + def get_config(self): + """ Returns the contents of the config override file, or None if none. """ + + @abstractmethod + def save_config(self, config_object): + """ Updates the contents of the config override file to those given. """ + + @abstractmethod + def config_exists(self): + """ Returns true if a config override file exists in the config volume. """ + + @abstractmethod + def volume_exists(self): + """ Returns whether the config override volume exists. """ + + @abstractmethod + def volume_file_exists(self, filename): + """ Returns whether the file with the given name exists under the config override volume. """ + + @abstractmethod + def get_volume_file(self, filename, mode='r'): + """ Returns a Python file referring to the given name under the config override volume. """ + + @abstractmethod + def write_volume_file(self, filename, contents): + """ Writes the given contents to the config override volumne, with the given filename. """ + + @abstractmethod + def remove_volume_file(self, filename): + """ Removes the config override volume file with the given filename. """ + + @abstractmethod + def list_volume_directory(self, path): + """ Returns a list of strings representing the names of the files found in the config override + directory under the given path. If the path doesn't exist, returns None. + """ + + @abstractmethod + def save_volume_file(self, filename, flask_file): + """ Saves the given flask file to the config override volume, with the given + filename. + """ + + @abstractmethod + def requires_restart(self, app_config): + """ If true, the configuration loaded into memory for the app does not match that on disk, + indicating that this container requires a restart. + """ + + @abstractmethod + def get_volume_path(self, directory, filename): + """ Helper for constructing file paths, which may differ between providers. For example, + kubernetes can't have subfolders in configmaps """ diff --git a/config_app/util/config.py b/config_app/util/config.py new file mode 100644 index 000000000..f01e2565d --- /dev/null +++ b/config_app/util/config.py @@ -0,0 +1,21 @@ +import os +from util.config.provider import TestConfigProvider, KubernetesConfigProvider, FileConfigProvider + +ROOT_DIR = os.path.dirname(os.path.abspath(__file__)) +CONF_DIR = os.getenv("QUAYCONF", os.path.join(ROOT_DIR, "conf/")) +OVERRIDE_CONFIG_DIRECTORY = os.path.join(CONF_DIR, 'stack/') + + +def get_config_provider(config_volume, yaml_filename, py_filename, testing=False, kubernetes=False): + """ Loads and returns the config provider for the current environment. """ + if testing: + return TestConfigProvider() + + if kubernetes: + return KubernetesConfigProvider(config_volume, yaml_filename, py_filename) + + return FileConfigProvider(config_volume, yaml_filename, py_filename) + + +config_provider = get_config_provider(OVERRIDE_CONFIG_DIRECTORY, 'config.yaml', 'config.py', + testing=False, kubernetes=False) \ No newline at end of file diff --git a/config_app/util/fileprovider.py b/config_app/util/fileprovider.py new file mode 100644 index 000000000..ac2ceec40 --- /dev/null +++ b/config_app/util/fileprovider.py @@ -0,0 +1,60 @@ +import os +import logging + +from util.config.provider.baseprovider import export_yaml, CannotWriteConfigException +from util.config.provider.basefileprovider import BaseFileProvider + + +logger = logging.getLogger(__name__) + + +def _ensure_parent_dir(filepath): + """ Ensures that the parent directory of the given file path exists. """ + try: + parentpath = os.path.abspath(os.path.join(filepath, os.pardir)) + if not os.path.isdir(parentpath): + os.makedirs(parentpath) + except IOError as ioe: + raise CannotWriteConfigException(str(ioe)) + + +class FileConfigProvider(BaseFileProvider): + """ Implementation of the config provider that reads and writes the data + from/to the file system. """ + def __init__(self, config_volume, yaml_filename, py_filename): + super(FileConfigProvider, self).__init__(config_volume, yaml_filename, py_filename) + + @property + def provider_id(self): + return 'file' + + def save_config(self, config_obj): + export_yaml(config_obj, self.yaml_path) + + def write_volume_file(self, filename, contents): + filepath = os.path.join(self.config_volume, filename) + _ensure_parent_dir(filepath) + + try: + with open(filepath, mode='w') as f: + f.write(contents) + except IOError as ioe: + raise CannotWriteConfigException(str(ioe)) + + return filepath + + def remove_volume_file(self, filename): + filepath = os.path.join(self.config_volume, filename) + os.remove(filepath) + + def save_volume_file(self, filename, flask_file): + filepath = os.path.join(self.config_volume, filename) + _ensure_parent_dir(filepath) + + # Write the file. + try: + flask_file.save(filepath) + except IOError as ioe: + raise CannotWriteConfigException(str(ioe)) + + return filepath diff --git a/config_app/web.py b/config_app/web.py index c98239f38..967a4a11a 100644 --- a/config_app/web.py +++ b/config_app/web.py @@ -1,6 +1,8 @@ from app import app as application from config_endpoints.setup_web import setup_web +from config_endpoints.api import api_bp -application.register_blueprint(setup_web) +# application.register_blueprint(setup_web) +application.register_blueprint(api_bp, url_prefix='/api') diff --git a/config_app/webpack.config.js b/config_app/webpack.config.js new file mode 100644 index 000000000..4b52f243e --- /dev/null +++ b/config_app/webpack.config.js @@ -0,0 +1,60 @@ +const webpack = require('webpack'); +const path = require('path'); + +let config = { + entry: { + configapp: "./js/main.ts" + }, + output: { + path: path.resolve(__dirname, "static/build"), + filename: '[name]-quay-frontend.bundle.js', + }, + resolve: { + extensions: [".ts", ".js"], + modules: [ + // Allows us to use the top-level node modules + path.resolve(__dirname, '../node_modules'), + ] + }, + externals: { + angular: "angular", + jquery: "$", + // moment: "moment", + // "raven-js": "Raven", + }, + module: { + rules: [ + { + test: /\.ts$/, + use: ["ts-loader"], + exclude: /node_modules/ + }, + { + test: /\.css$/, + use: [ + "style-loader", + "css-loader?minimize=true", + ], + }, + { + test: /\.html$/, + use: [ + 'ngtemplate-loader?relativeTo=' + (path.resolve(__dirname)), + 'html-loader', + ] + }, + ] + }, + plugins: [ + // Replace references to global variables with associated modules + new webpack.ProvidePlugin({ + FileSaver: 'file-saver', + angular: "angular", + $: "jquery", + // moment: "moment", + }), + ], + devtool: "cheap-module-source-map", +}; + +module.exports = config; diff --git a/endpoints/common.py b/endpoints/common.py index a5113ca79..82040bb06 100644 --- a/endpoints/common.py +++ b/endpoints/common.py @@ -24,7 +24,7 @@ from _init import __version__ logger = logging.getLogger(__name__) -JS_BUNDLE_NAME = 'main' +JS_BUNDLE_NAME = 'bundle' def common_login(user_uuid, permanent_session=True): @@ -73,9 +73,9 @@ def _list_files(path, extension, contains=""): return [join_path(dp, f) for dp, _, files in os.walk(filepath) for f in files if matches(f)] -def render_page_template(name, route_data=None, js_bundle_name=JS_BUNDLE_NAME, **kwargs): +def render_page_template(name, route_data=None, **kwargs): """ Renders the page template with the given name as the response and returns its contents. """ - main_scripts = _list_files('build', 'js', js_bundle_name) + main_scripts = _list_files('build', 'js', JS_BUNDLE_NAME) use_cdn = app.config.get('USE_CDN', True) if request.args.get('use_cdn') is not None: diff --git a/local-config-app.sh b/local-config-app.sh index 6dc723670..e2d63562e 100755 --- a/local-config-app.sh +++ b/local-config-app.sh @@ -1,11 +1,11 @@ #!/usr/bin/env bash cat << "EOF" __ __ - / \ / \ ______ _ _ __ __ __ - / /\ / /\ \ / __ \ | | | | / \ \ \ / / -/ / / / \ \ | | | | | | | | / /\ \ \ / -\ \ \ \ / / | |__| | | |__| | / ____ \ | | - \ \/ \ \/ / \_ ___/ \____/ /_/ \_\ |_| + / \ / \ ______ _ _ __ __ __ _____ ____ _ _ _____ _____ _____ + / /\ / /\ \ / __ \ | | | | / \ \ \ / / / ____| / __ \ | \ | | | ___| |_ _| / ____| +/ / / / \ \ | | | | | | | | / /\ \ \ / | | | | | | | \| | | |__ | | | | _ +\ \ \ \ / / | |__| | | |__| | / ____ \ | | | |____ | |__| | | . ` | | __| _| |_ | |__| | + \ \/ \ \/ / \_ ___/ \____/ /_/ \_\ |_| \_____| \____/ |_| \_| |_| |_____| \_____| \__/ \__/ \ \__ \___\ by CoreOS diff --git a/package.json b/package.json index 5ed245cb3..1a084ab37 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,10 @@ "watch": "npm run clean && webpack --watch", "lint": "tslint --type-check -p tsconfig.json -e **/*.spec.ts", "analyze": "NODE_ENV=production webpack --profile --json | awk '{if(NR>1)print}' > static/build/stats.json && webpack-bundle-analyzer --mode static -r static/build/report.html static/build/stats.json", - "clean": "rm -f static/build/*" + "clean": "rm -f static/build/*", + + "clean-config-app": "rm -f config_app/static/build/*", + "watch-config-app": "npm run clean-config-app && cd config_app && webpack --watch" }, "repository": { "type": "git", diff --git a/static/configappjs/index.js b/static/configappjs/index.js deleted file mode 100644 index 9bac6fe97..000000000 --- a/static/configappjs/index.js +++ /dev/null @@ -1,5 +0,0 @@ - - -const setupPage = ''; -angular.module('quay', [setupPage]); -console.log('Hello world! I\'m the config app'); \ No newline at end of file diff --git a/web.py b/web.py index ed2ef24b6..fd3b1768e 100644 --- a/web.py +++ b/web.py @@ -11,15 +11,7 @@ from endpoints.webhooks import webhooks from endpoints.wellknown import wellknown -import os -is_config_mode = 'FLAGGED_CONFIG_MODE' in os.environ -print('\n\n\nAre we in config mode?') -print(is_config_mode) - - application.register_blueprint(web) - - application.register_blueprint(githubtrigger, url_prefix='/oauth2') application.register_blueprint(gitlabtrigger, url_prefix='/oauth2') application.register_blueprint(oauthlogin, url_prefix='/oauth2') diff --git a/webpack.config.js b/webpack.config.js index 51df57480..6944a3a64 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -3,10 +3,7 @@ const path = require('path'); let config = { - entry: { - main: "./static/js/main.ts", - configapp: "./static/configappjs/index.js" - }, + entry: "./static/js/main.ts", output: { path: path.resolve(__dirname, "static/build"), publicPath: "/static/build/",