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
This commit is contained in:
Sam Chow 2018-05-14 15:45:26 -04:00
parent 15c15faf30
commit d080ca2cc6
49 changed files with 8996 additions and 153 deletions

View file

@ -1,3 +1,3 @@
app: PYTHONPATH="../" gunicorn -c conf/gunicorn_local.py application:application app: PYTHONPATH="../" gunicorn -c conf/gunicorn_local.py application:application
# webpack: npm run watch # webpack: npm run watch-config-app

View file

@ -1,3 +1,4 @@
from flask import Flask, request, Request from flask import Flask
app = Flask(__name__) app = Flask(__name__)

View file

@ -1,15 +1,9 @@
# import os
# import logging
# import logging.config
# from util.log import logfile_path
from app import app as application from app import app as application
# Bind all of the blueprints # Bind all of the blueprints
import web import web
if __name__ == '__main__': if __name__ == '__main__':
logging.config.fileConfig(logfile_path(debug=True), disable_existing_loggers=False) logging.config.fileConfig(logfile_path(debug=True), disable_existing_loggers=False)
application.run(port=5000, debug=True, threaded=True, host='0.0.0.0') application.run(port=5000, debug=True, threaded=True, host='0.0.0.0')

View file

@ -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
}

View file

@ -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. """ """ 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:
# 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()}
contents = render_template(name, contents = render_template(name,
route_data=route_data, route_data=route_data,
# external_styles=external_styles, main_scripts=main_scripts,
# 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,
**kwargs) **kwargs)
resp = make_response(contents) resp = make_response(contents)
resp.headers['X-FRAME-OPTIONS'] = 'DENY' resp.headers['X-FRAME-OPTIONS'] = 'DENY'
return resp 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}

View file

@ -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 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 common import render_page_template
from config_app.config_endpoints.common import generate_route_data
from util.cache import no_cache 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') 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': ''}) @setup_web.route('/', methods=['GET'], defaults={'path': ''})
@no_cache @no_cache
def index(path, **kwargs): 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

View file

@ -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': '<extensions',
'reset': '=?reset'
},
controller: function($rootScope, $scope, $element, ApiService) {
var MEGABYTE = 1000000;
var MAX_FILE_SIZE = MAX_FILE_SIZE_MB * MEGABYTE;
var MAX_FILE_SIZE_MB = 100;
var number = $rootScope.__fileUploadBoxIdCounter || 0;
$rootScope.__fileUploadBoxIdCounter = number + 1;
$scope.boxId = number;
$scope.state = 'clear';
$scope.selectedFiles = [];
var conductUpload = function(file, url, fileId, mimeType, progressCb, doneCb) {
var request = new XMLHttpRequest();
request.open('PUT', url, true);
request.setRequestHeader('Content-Type', mimeType);
request.onprogress = function(e) {
$scope.$apply(function() {
if (e.lengthComputable) { progressCb((e.loaded / e.total) * 100); }
});
};
request.onerror = function() {
$scope.$apply(function() { doneCb(false, 'Error when uploading'); });
};
request.onreadystatechange = function() {
var state = request.readyState;
var status = request.status;
if (state == 4) {
if (Math.floor(status / 100) == 2) {
$scope.$apply(function() { doneCb(true, fileId); });
} else {
var message = request.statusText;
if (status == 413) {
message = 'Selected file too large to upload';
}
$scope.$apply(function() { doneCb(false, message); });
}
}
};
request.send(file);
};
var uploadFiles = function(callback) {
var currentIndex = 0;
var fileIds = [];
var progressCb = function(progress) {
$scope.uploadProgress = progress;
};
var doneCb = function(status, messageOrId) {
if (status) {
fileIds.push(messageOrId);
currentIndex++;
performFileUpload();
} else {
callback(false, messageOrId);
}
};
var performFileUpload = function() {
// If we have finished uploading all of the files, invoke the overall callback
// with the list of file IDs.
if (currentIndex >= $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;
});

View file

@ -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 {}

View file

@ -0,0 +1,8 @@
<div class="config-bool-field-element">
<form name="fieldform" novalidate>
<label>
<input type="checkbox" ng-model="binding">
<span ng-transclude/>
</label>
</form>
</div>

View file

@ -0,0 +1,76 @@
<div class="config-certificates-field-element">
<div class="resource-view" resource="certificatesResource" error-message="'Could not load certificates list'">
<!-- File -->
<div class="co-alert co-alert-warning" ng-if="certInfo.status == 'file'">
<code>extra_ca_certs</code> 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.
</div>
<div ng-if="certInfo.status != 'file'">
<div class="description">
<p>This section lists any custom or self-signed SSL certificates that are installed in the <span class="registry-name"></span> container on startup after being read from the <code>extra_ca_certs</code> directory in the configuration volume.
</p>
<p>
Custom certificates are typically used in place of publicly signed certificates for corporate-internal services.
</p>
<p>Please <strong>make sure</strong> that all custom names used for downstream services (such as Clair) are listed in the certificates below.</p>
</div>
<table class="config-table" style="margin-bottom: 20px;">
<tr>
<td>Upload certificates:</td>
<td>
<div class="file-upload-box"
select-message="Select custom certificate to add to configuration. Must be in PEM format and end extension '.crt'"
files-selected="handleCertsSelected(files, callback)"
reset="resetUpload"
extensions="['.crt']"></div>
</td>
</tr>
</table>
<table class="co-table">
<thead>
<td>Certificate Filename</td>
<td>Status</td>
<td>Names Handled</td>
<td class="options-col"></td>
</thead>
<tr ng-repeat="certificate in certInfo.certs" ng-if="!certsUploading">
<td>{{ certificate.path }}</td>
<td class="cert-status">
<div ng-if="certificate.error" class="red">
<i class="fa fa-exclamation-circle"></i>
Error: {{ certificate.error }}
</div>
<div ng-if="certificate.expired" class="orange">
<i class="fa fa-exclamation-triangle"></i>
Certificate is expired
</div>
<div ng-if="!certificate.error && !certificate.expired" class="green">
<i class="fa fa-check-circle"></i>
Certificate is valid
</div>
</td>
<td>
<div class="empty" ng-if="!certificate.names">(None)</div>
<a class="dns-name" ng-href="http://{{ name }}" ng-repeat="name in certificate.names" ng-safenewtab>{{ name }}</a>
</td>
<td class="options-col">
<span class="cor-options-menu">
<span class="cor-option" option-click="deleteCert(certificate.path)">
<i class="fa fa-times"></i> Delete Certificate
</span>
</span>
</td>
</tr>
</table>
<div ng-if="certsUploading" style="margin-top: 20px; text-align: center;">
<div class="cor-loader-inline"></div>
Uploading, validating and updating certificate(s)
</div>
<div class="empty" ng-if="!certInfo.certs.length && !certsUploading" style="margin-top: 20px;">
<div class="empty-primary-msg">No custom certificates found.</div>
</div>
</div>
</div>
</div>

View file

@ -0,0 +1,46 @@
<div class="config-contact-field-element">
<table>
<tr>
<td>
<div class="dropdown">
<button class="btn btn-default dropdown-toggle" type="button" data-toggle="dropdown">
<span ng-switch="kind">
<span ng-switch-when="mailto"><i class="fa fa-envelope"></i>E-mail</span>
<span ng-switch-when="irc"><i class="fa fa-comment"></i>IRC</span>
<span ng-switch-when="tel"><i class="fa fa-phone"></i>Phone</span>
<span ng-switch-default><i class="fa fa-ticket"></i>URL</span>
</span>
<span class="caret"></span>
</button>
<ul class="dropdown-menu" role="menu">
<li role="presentation">
<a role="menuitem" tabindex="-1" ng-click="kind = 'mailto'">
<i class="fa fa-envelope"></i> E-mail
</a>
</li>
<li role="presentation">
<a role="menuitem" tabindex="-1" ng-click="kind = 'irc'">
<i class="fa fa-comment"></i> IRC
</a>
</li>
<li role="presentation">
<a role="menuitem" tabindex="-1" ng-click="kind = 'tel'">
<i class="fa fa-phone"></i> Telephone
</a>
</li>
<li role="presentation">
<a role="menuitem" tabindex="-1" ng-click="kind = 'http'">
<i class="fa fa-ticket"></i> URL
</a>
</li>
</ul>
</div>
</td>
<td>
<form>
<input class="form-control" placeholder="{{ getPlaceholder(kind) }}" ng-model="value">
</form>
</td>
</tr>
</table>
</div>

View file

@ -0,0 +1,4 @@
<div class="config-contacts-field-element">
<div class="config-contact-field" binding="item.value" ng-repeat="item in items">
</div>
</div>

View file

@ -0,0 +1,13 @@
<div class="config-file-field-element">
<span ng-show="uploadProgress == null">
<span ng-if="hasFile">
<code>/conf/stack/{{ filename }}</code>
<span style="margin-left: 20px; display: inline-block;">Select a replacement file:</span>
</span>
<span class="nofile" ng-if="!hasFile && skipCheckFile != 'true'">Please select a file to upload as <b>{{ filename }}</b>: </span>
<input type="file" ng-file-select="onFileSelect($files)">
</span>
<span ng-show="uploadProgress != null">
Uploading file as <strong>{{ filename }}</strong>... {{ uploadProgress }}%
</span>
</div>

View file

@ -0,0 +1,17 @@
<div class="config-list-field-element">
<ul ng-show="binding && binding.length">
<li class="item" ng-repeat="item in binding">
<span class="item-title">{{ item }}</span>
<span class="item-delete">
<a ng-click="removeItem(item)">Remove</a>
</span>
</li>
</ul>
<span class="empty" ng-if="!binding || binding.length == 0">No {{ itemTitle }}s defined</span>
<form class="form-control-container" ng-submit="addItem()">
<input type="text" class="form-control" placeholder="{{ placeholder }}"
ng-pattern="getRegexp(itemPattern)"
ng-model="newItemName" style="display: inline-block">
<button class="btn btn-default" style="display: inline-block">Add</button>
</form>
</div>

View file

@ -0,0 +1,20 @@
<div class="config-map-field-element">
<table class="table" ng-show="hasValues(binding)">
<tr class="item" ng-repeat="(key, value) in binding">
<td class="item-title">{{ key }}</td>
<td class="item-value">{{ value }}</td>
<td class="item-delete">
<a ng-click="removeKey(key)">Remove</a>
</td>
</tr>
</table>
<span class="empty" ng-if="!hasValues(binding)">No entries defined</span>
<form class="form-control-container" ng-submit="addEntry()">
Add Key-Value:
<select ng-model="newKey">
<option ng-repeat="key in keys" value="{{ key }}">{{ key }}</option>
</select>
<input type="text" class="form-control" placeholder="Value" ng-model="newValue">
<button class="btn btn-default" style="display: inline-block">Add Entry</button>
</form>
</div>

View file

@ -0,0 +1,6 @@
<div class="config-numeric-field-element">
<form name="fieldform" novalidate>
<input type="number" class="form-control" placeholder="{{ placeholder || '' }}"
ng-model="bindinginternal" ng-trim="false" ng-minlength="1" required>
</form>
</div>

View file

@ -0,0 +1 @@
<div class="config-parsed-field-element"></div>

View file

@ -0,0 +1,29 @@
<div class="config-service-key-field-element">
<!-- Loading -->
<div class="cor-loader" ng-if="loading"></div>
<!-- Loading error -->
<div class="co-alert co-alert-warning" ng-if="loadError">
Could not load service keys
</div>
<!-- Key config -->
<div ng-show="!loading && !loadError">
<div ng-show="hasValidKey">
<i class="fa fa-check"></i>
Valid key for service <code>{{ serviceName }}</code> exists
</div>
<div ng-show="!hasValidKey">
No valid key found for service <code>{{ serviceName }}</code>
<a class="co-modify-link" ng-click="showRequestServiceKey()">Create Key</a>
</div>
</div>
<!-- Note: This field is a hidden text field that binds to a model that is set to non-empty
when there is a valid key. This allows us to use the existing Angular form validation
code.
-->
<input type="text" ng-model="hasValidKeyStr" ng-required="true" style="position: absolute; top: 0px; left: 0px; visibility: hidden; width: 0px; height: 0px;">
<div class="request-service-key-dialog" request-key-info="requestKeyInfo" key-created="handleKeyCreated(key)"></div>
</div>

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,10 @@
<div class="config-string-field-element">
<form name="fieldform" novalidate>
<input type="text" class="form-control" placeholder="{{ placeholder || '' }}"
ng-model="binding" ng-trim="false" ng-minlength="1"
ng-pattern="getRegexp(pattern)" ng-required="!isOptional">
<div class="alert alert-danger" ng-show="errorMessage">
{{ errorMessage }}
</div>
</form>
</div>

View file

@ -0,0 +1,6 @@
<div class="config-string-list-field-element">
<form name="fieldform" novalidate>
<input type="text" class="form-control" placeholder="{{ placeholder || '' }}"
ng-model="internalBinding" ng-trim="true" ng-minlength="1" ng-required="!isOptional">
</form>
</div>

View file

@ -0,0 +1,10 @@
<div class="config-variable-field-element">
<div class="btn-group">
<button type="button" class="btn btn-default"
ng-repeat="section in sections"
ng-click="setSection(section)"
ng-class="section == currentSection ? 'active' : ''">{{ section.title }}</button>
</div>
<span ng-transclude></span>
</div>

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

36
config_app/js/main.ts Normal file
View file

@ -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$/));

View file

@ -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:' +
'<form style="margin-top: 10px" action="javascript:$(\'.btn-continue\').click();void(0)">' +
'<input id="freshPassword" class="form-control" type="password" placeholder="Current Password">' +
'</form>',
"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;
}]);

View file

@ -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;
}]);

View file

@ -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;
}]);

View file

@ -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;
}]);

View file

@ -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;
}]);

View file

@ -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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
return adjusted;
};
utilService.stringToHTML = function(text) {
text = utilService.escapeHtmlString(text);
text = text.replace(/\n/g, '<br>');
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": "<div class='alert-icon-container-container'><div class='alert-icon-container'><div class='alert-icon'></div></div></div>" + message,
"buttons": {},
"className": "co-dialog fatal-error",
"closeButton": false
});
};
return service;
}]);

View file

@ -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 <code>config.yaml</code> file found in <code>conf/stack</code> 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 <code>/conf/stack</code>: " +
"<br><br><pre>docker run -v /path/to/config:/conf/stack</pre>" +
"<br>Once fixed, restart the container. For more information, " +
"<a href='https://coreos.com/docs/enterprise-registry/initial-setup/'>" +
"Read the Setup Guide</a>"
var title = "Missing configuration volume";
CoreDialog.fatal(title, message);
};
$scope.parseDbUri = function(value) {
if (!value) { return null; }
// Format: mysql+pymysql://<username>:<url escaped password>@<hostname>/<database_name>
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();
};
})();

View file

@ -0,0 +1,311 @@
<div>
<div>
<div class="cor-loader" ng-show="currentStep == States.LOADING"></div>
<div class="page-content" quay-show="Features.SUPER_USERS && currentStep == States.CONFIG">
<div class="cor-title">
<span class="cor-title-link"></span>
<span class="cor-title-content">Quay Enterprise Setup</span>
</div>
<div class="co-main-content-panel" style="padding: 20px;">
<div class="co-alert alert alert-info">
<span class="cor-step-bar" progress="stepProgress">
<span class="cor-step" title="Configure Database" text="1"></span>
<span class="cor-step" title="Setup Database" icon="database"></span>
<span class="cor-step" title="Container Restart" icon="refresh"></span>
<span class="cor-step" title="Create Superuser" text="2"></span>
<span class="cor-step" title="Configure Registry" text="3"></span>
<span class="cor-step" title="Validate Configuration" text="4"></span>
<span class="cor-step" title="Container Restart" icon="refresh"></span>
<span class="cor-step" title="Setup Complete" icon="check"></span>
</span>
<div><strong>Almost done!</strong></div>
<div>Configure your Redis database and other settings below</div>
</div>
<!--<config-setup-tool is-active="isStep(currentStep, States.CONFIG)"-->
<!--configuration-saved="configurationSaved(config)"></config-setup-tool>-->
<div class="config-setup-tool" is-active="true"
configuration-saved="configurationSaved(config)"></div>
</div>
</div>
</div>
<!-- Modal message dialog -->
<div class="co-dialog modal fade initial-setup-modal" id="setupModal">
<div class="modal-dialog">
<div class="modal-content">
<!-- Header -->
<div class="modal-header">
<span class="cor-step-bar" progress="stepProgress">
<span class="cor-step" title="Configure Database" text="1"></span>
<span class="cor-step" title="Setup Database" icon="database"></span>
<span class="cor-step" title="Container Restart" icon="refresh"></span>
<span class="cor-step" title="Create Superuser" text="2"></span>
<span class="cor-step" title="Configure Registry" text="3"></span>
<span class="cor-step" title="Validate Configuration" text="4"></span>
<span class="cor-step" title="Container Restart" icon="refresh"></span>
<span class="cor-step" title="Setup Complete" icon="check"></span>
</span>
<h4 class="modal-title"><span><span class="registry-name" is-short="true"></span> Setup</h4>
</div>
<form id="superuserForm" name="superuserForm" ng-submit="createSuperUser()">
<!-- Content: CREATE_SUPERUSER or SUPERUSER_ERROR or CREATING_SUPERUSER -->
<div class="modal-body config-setup-tool-element" style="padding: 20px"
ng-show="isStep(currentStep, States.CREATE_SUPERUSER, States.SUPERUSER_ERROR, States.CREATING_SUPERUSER)">
<p>A superuser is the main administrator of your <span class="registry-name" is-short="true"></span>. Only superusers can edit configuration settings.</p>
<div class="form-group">
<label>Username</label>
<input class="form-control" type="text" ng-model="superUser.username"
ng-pattern="/^[a-z0-9_]{4,30}$/" required>
<div class="help-text">Minimum 4 characters in length</div>
</div>
<div class="form-group">
<label>Email address</label>
<input class="form-control" type="email" ng-model="superUser.email" required>
</div>
<div class="form-group">
<label>Password</label>
<input class="form-control" type="password" ng-model="superUser.password"
ng-pattern="/^[^\s]+$/"
ng-minlength="8" required>
<div class="help-text">Minimum 8 characters in length</div>
</div>
<div class="form-group">
<label>Repeat Password</label>
<input class="form-control" type="password" ng-model="superUser.repeatPassword"
match="superUser.password" required>
</div>
</div>
<!-- Footer: CREATE_SUPERUSER or SUPERUSER_ERROR -->
<div class="modal-footer"
ng-show="isStep(currentStep, States.CREATE_SUPERUSER, States.SUPERUSER_ERROR)">
<button type="submit" class="btn btn-primary" ng-disabled="!superuserForm.$valid">
Create Super User
</button>
</div>
</form>
<!-- Content: DB_RESTARTING or CONFIG_RESTARTING -->
<div class="modal-body" style="padding: 20px;"
ng-show="isStep(currentStep, States.DB_RESTARTING, States.CONFIG_RESTARTING)">
<h4 style="margin-bottom: 20px;">
<i class="fa fa-lg fa-refresh" style="margin-right: 10px;"></i>
<span class="registry-name"></span> is currently being restarted
</h4>
This can take several minutes. If the container does not restart on its own,
please re-execute the <code>docker run</code> command.
</div>
<!-- Content: READY -->
<div class="modal-body" style="padding: 20px;"
ng-show="isStep(currentStep, States.READY)">
<h4>Installation and setup of <span class="registry-name"></span> is complete</h4>
You can now invite users to join, create organizations and start pushing and pulling
repositories.
<strong ng-if="hasSSL" style="margin-top: 20px;">
Note: SSL is enabled. Please make sure to visit with
an <u>https</u> prefix
</strong>
</div>
<!-- Content: VALID_CONFIG -->
<div class="modal-body" style="padding: 20px;"
ng-show="isStep(currentStep, States.VALID_CONFIG)">
<h4>All configuration has been validated and saved</h4>
The container must be restarted to apply the configuration changes.
</div>
<!-- Content: DB_SETUP_SUCCESS -->
<div class="modal-body" style="padding: 20px;"
ng-show="isStep(currentStep, States.DB_SETUP_SUCCESS)">
<h4>The database has been setup and is ready</h4>
The container must be restarted to apply the configuration changes.
</div>
<!-- Content: DB_SETUP or DB_SETUP_ERROR -->
<div class="modal-body" style="padding: 20px;"
ng-show="isStep(currentStep, States.DB_SETUP, States.DB_SETUP_ERROR)">
<h4>
<i class="fa fa-lg fa-database" style="margin-right: 10px;"></i>
<span class="registry-name"></span> is currently setting up its database
schema
</h4>
This can take several minutes.
</div>
<!-- Content: CONFIG_DB or DB_ERROR or VALIDATING_DB or SAVING_DB -->
<div class="modal-body validate-database config-setup-tool-element"
ng-show="isStep(currentStep, States.CONFIG_DB, States.DB_ERROR, States.VALIDATING_DB, States.SAVING_DB)">
<p>
Please enter the connection details for your <strong>empty</strong> database. The schema will be created in the following step.</p>
</p>
<div class="config-parsed-field" binding="databaseUri"
parser="parseDbUri(value)"
serializer="serializeDbUri(fields)">
<table class="config-table">
<tr>
<td class="non-input">Database Type:</td>
<td>
<select ng-model="fields.kind">
<option value="mysql+pymysql">MySQL</option>
<option value="postgresql">Postgres</option>
</select>
</td>
</tr>
<tr ng-show="fields.kind">
<td>Database Server:</td>
<td>
<span class="config-string-field" binding="fields.server"
placeholder="dbserverhost"
pattern="{{ HOSTNAME_REGEX }}"
validator="validateHostname(value)">></span>
<div class="help-text">
The server (and optionally, custom port) where the database lives
</div>
</td>
</tr>
<tr ng-show="fields.kind">
<td>Username:</td>
<td>
<span class="config-string-field" binding="fields.username"
placeholder="someuser"></span>
<div class="help-text">This user must have <strong>full access</strong> to the database</div>
</td>
</tr>
<tr ng-show="fields.kind">
<td>Password:</td>
<td>
<input class="form-control" type="password" ng-model="fields.password"></span>
</td>
</tr>
<tr ng-show="fields.kind">
<td>Database Name:</td>
<td>
<span class="config-string-field" binding="fields.database"
placeholder="registry-database"></span>
</td>
</tr>
<tr ng-show="fields.kind">
<td>SSL Certificate:</td>
<td>
<span class="config-file-field" filename="database.pem"
skip-check-file="true" has-file="currentState.hasDatabaseSSLCert"></span>
<div class="help-text">Optional SSL certicate (in PEM format) to use to connect to the database</div>
</td>
</tr>
</table>
</div>
</div>
<!-- Footer: CREATING_SUPERUSER -->
<div class="modal-footer working" ng-show="isStep(currentStep, States.CREATING_SUPERUSER)">
<span class="cor-loader-inline"></span> Creating superuser...
</div>
<!-- Footer: SUPERUSER_ERROR -->
<div class="modal-footer alert alert-warning"
ng-show="isStep(currentStep, States.SUPERUSER_ERROR)">
{{ errors.SuperuserCreationError }}
</div>
<!-- Footer: DB_SETUP_ERROR -->
<div class="modal-footer alert alert-warning"
ng-show="isStep(currentStep, States.DB_SETUP_ERROR)">
Database Setup Failed. Please report this to support: {{ errors.DatabaseSetupError }}
</div>
<!-- Footer: DB_ERROR -->
<div class="modal-footer alert alert-warning" ng-show="isStep(currentStep, States.DB_ERROR)">
Database Validation Issue: {{ errors.DatabaseValidationError }}
</div>
<!-- Footer: CONFIG_DB or DB_ERROR -->
<div class="modal-footer"
ng-show="isStep(currentStep, States.CONFIG_DB, States.DB_ERROR)">
<span class="left-align" ng-show="isStep(currentStep, States.DB_ERROR)">
<i class="fa fa-warning"></i>
Problem Detected
</span>
<button type="submit" class="btn btn-primary"
ng-disabled="!databaseUri"
ng-click="validateDatabase()">
Validate Database Settings
</button>
</div>
<!-- Footer: READY -->
<div class="modal-footer"
ng-show="isStep(currentStep, States.READY)">
<span class="left-align">
<i class="fa fa-check"></i>
Installation Complete!
</span>
<a ng-click="showSuperuserPanel()" class="btn btn-primary">
View Superuser Panel
</a>
</div>
<!-- Footer: VALID_CONFIG -->
<div class="modal-footer"
ng-show="isStep(currentStep, States.VALID_CONFIG)">
<span class="left-align">
<i class="fa fa-check"></i>
Configuration Validated and Saved
</span>
<button type="submit" class="btn btn-primary"
ng-click="restartContainer(States.CONFIG_RESTARTING)">
Restart Container
</button>
</div>
<!-- Footer: DB_SETUP_SUCCESS -->
<div class="modal-footer"
ng-show="isStep(currentStep, States.DB_SETUP_SUCCESS)">
<span class="left-align">
<i class="fa fa-check"></i>
Database Setup and Ready
</span>
<button type="submit" class="btn btn-primary"
ng-click="restartContainer(States.DB_RESTARTING)">
Restart Container
</button>
</div>
<!-- Footer: DB_SETUP -->
<div class="modal-footer working" ng-show="isStep(currentStep, States.DB_SETUP)">
<span class="cor-loader-inline"></span> Setting up database...
</div>
<!-- Footer: SAVING_DB -->
<div class="modal-footer working" ng-show="isStep(currentStep, States.SAVING_DB)">
<span class="cor-loader-inline"></span> Saving database configuration...
</div>
<!-- Footer: VALIDATING_DB -->
<div class="modal-footer working" ng-show="isStep(currentStep, States.VALIDATING_DB)">
<span class="cor-loader-inline"></span> Testing database settings...
</div>
<!-- Footer: DB_RESTARTING or CONFIG_RESTARTING-->
<div class="modal-footer working"
ng-show="isStep(currentStep, States.DB_RESTARTING, States.CONFIG_RESTARTING)">
<span class="cor-loader-inline"></span> Waiting for container to restart...
</div>
</div><!-- /.modal-content -->
</div><!-- /.modal-dialog -->
</div><!-- /.modal -->
</div>

File diff suppressed because it is too large Load diff

View file

@ -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;h<e.length;h++){var i=e[h];"function"==typeof i&&(g=i(g,c))}a.append(d,g)}if(null!=b.file){var j=b.fileFormDataName||"file";if("[object Array]"===Object.prototype.toString.call(b.file))for(var k="[object String]"===Object.prototype.toString.call(j),h=0;h<b.file.length;h++)a.append(k?j+h:j[h],b.file[h],b.file[h].name);else a.append(j,b.file,b.file.name)}return a},b.data=d,c(b)},this.http=function(a){return c(a)}}]),a.directive("ngFileSelect",["$parse","$timeout",function(a,b){return function(c,d,e){var f=a(e.ngFileSelect);d.bind("change",function(a){var d,e,g=[];if(d=a.target.files,null!=d)for(e=0;e<d.length;e++)g.push(d.item(e));b(function(){f(c,{$files:g,$event:a})})}),("ontouchstart"in window||navigator.maxTouchPoints>0||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;c<b.length;c++)f(a,b[c]);i--})}else i++,b.file(function(b){i--,a.push(b)})}if("draggable"in document.createElement("span")){var g=null,h=a(e.ngFileDrop);d[0].addEventListener("dragover",function(a){b.cancel(g),a.stopPropagation(),a.preventDefault(),d.addClass(e.ngFileDragOverClass||"dragover")},!1),d[0].addEventListener("dragleave",function(){g=b(function(){d.removeClass(e.ngFileDragOverClass||"dragover")})},!1);var i=0;d[0].addEventListener("drop",function(a){a.stopPropagation(),a.preventDefault(),d.removeClass(e.ngFileDragOverClass||"dragover");var g=[],j=a.dataTransfer.items;if(j&&j.length>0&&j[0].webkitGetAsEntry)for(var k=0;k<j.length;k++)f(g,j[k].webkitGetAsEntry());else{var l=a.dataTransfer.files;if(null!=l)for(var k=0;k<l.length;k++)g.push(l.item(k))}!function m(d){b(function(){i?m(10):h(c,{$files:g,$event:a})},d||0)}()},!1)}}}])}();

View file

@ -1,12 +0,0 @@
<!DOCTYPE html>
<html ng-app="quay">
<head>
<title>Config app</title>
</head>
<body>
<div>
<p>What is my purpose</p>
<p>You make tarballs</p>
</div>
</body>
</html>

View file

@ -0,0 +1,51 @@
<!DOCTYPE html>
<html ng-app="quay-config">
<head>
<script type="text/javascript">
window.__endpoints = {{ route_data|tojson|safe }}.paths;
</script>
<!--REMOTE CSS-->
<link rel="stylesheet" href="//netdna.bootstrapcdn.com/font-awesome/4.7.0/css/font-awesome.css" type="text/css">
<link rel="stylesheet" href="//netdna.bootstrapcdn.com/bootstrap/3.3.2/css/bootstrap.min.css" type="text/css">
<link rel="stylesheet" href="//fonts.googleapis.com/css?family=Source+Sans+Pro:300,400,700" type="text/css">
<link rel="stylesheet" href="//s3.amazonaws.com/cdn.core-os.net/icons/core-icons.css" type="text/css">
<link rel="stylesheet" href="//cdnjs.cloudflare.com/ajax/libs/bootstrap-datetimepicker/4.17.37/css/bootstrap-datetimepicker.min.css" type="text/css">
<link rel="stylesheet" href="//cdn.jsdelivr.net/cal-heatmap/3.3.10/cal-heatmap.css" type="text/css">
<link rel="stylesheet" href="//cdnjs.cloudflare.com/ajax/libs/ng-tags-input/3.1.1/ng-tags-input.min.css" type="text/css">
<!--REMOTE SCRIPTS-->
<script src="//code.jquery.com/jquery.js"></script>
<script src="//netdna.bootstrapcdn.com/bootstrap/3.3.2/js/bootstrap.min.js"></script>
<script src="//ajax.googleapis.com/ajax/libs/angularjs/1.5.3/angular.min.js"></script>
<!--<script src="//ajax.googleapis.com/ajax/libs/angularjs/1.5.3/angular-route.min.js"></script>-->
<script src="//ajax.googleapis.com/ajax/libs/angularjs/1.5.3/angular-sanitize.min.js"></script>
<!--<script src="//ajax.googleapis.com/ajax/libs/angularjs/1.5.3/angular-animate.min.js"></script>-->
<script src="//ajax.googleapis.com/ajax/libs/angularjs/1.5.3/angular-cookies.min.js"></script>
<script src="//cdn.jsdelivr.net/g/bootbox@4.1.0,underscorejs@1.5.2,restangular@1.2.0,d3js@3.3.3"></script>
<!--<script src="//cdn.jsdelivr.net/g/momentjs"></script>-->
<!--<script src="//cdnjs.cloudflare.com/ajax/libs/bootstrap-datepicker/1.2.0/js/bootstrap-datepicker.min.js"></script>-->
<!--<script src="//cdnjs.cloudflare.com/ajax/libs/bootstrap-datetimepicker/4.17.37/js/bootstrap-datetimepicker.min.js"></script>-->
<!--<script src="//cdn.ravenjs.com/3.1.0/angular/raven.min.js"></script>-->
<!--<script src="//cdn.jsdelivr.net/cal-heatmap/3.3.10/cal-heatmap.min.js"></script>-->
<!--<script src="//cdnjs.cloudflare.com/ajax/libs/angular-recaptcha/4.1.3/angular-recaptcha.min.js"></script>-->
<!--<script src="//cdnjs.cloudflare.com/ajax/libs/ng-tags-input/3.1.1/ng-tags-input.min.js"></script>-->
<!--<script src="//cdnjs.cloudflare.com/ajax/libs/corejs-typeahead/1.1.1/typeahead.bundle.min.js"></script>-->
{% for script_path in main_scripts %}
<script src="/static/{{ script_path }}"></script>
{% endfor %}
<title>Config app</title>
</head>
<body>
<div>
<p>What is my purpose</p>
<p>You make tarballs</p>
<div class="setup">
</div>
</div>
</div>
</body>
</html>

View file

View file

@ -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 """

21
config_app/util/config.py Normal file
View file

@ -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)

View file

@ -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

View file

@ -1,6 +1,8 @@
from app import app as application from app import app as application
from config_endpoints.setup_web import setup_web 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')

View file

@ -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;

View file

@ -24,7 +24,7 @@ from _init import __version__
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
JS_BUNDLE_NAME = 'main' JS_BUNDLE_NAME = 'bundle'
def common_login(user_uuid, permanent_session=True): 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)] 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. """ """ 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) use_cdn = app.config.get('USE_CDN', True)
if request.args.get('use_cdn') is not None: if request.args.get('use_cdn') is not None:

View file

@ -1,11 +1,11 @@
#!/usr/bin/env bash #!/usr/bin/env bash
cat << "EOF" cat << "EOF"
__ __ __ __
/ \ / \ ______ _ _ __ __ __ / \ / \ ______ _ _ __ __ __ _____ ____ _ _ _____ _____ _____
/ /\ / /\ \ / __ \ | | | | / \ \ \ / / / /\ / /\ \ / __ \ | | | | / \ \ \ / / / ____| / __ \ | \ | | | ___| |_ _| / ____|
/ / / / \ \ | | | | | | | | / /\ \ \ / / / / / \ \ | | | | | | | | / /\ \ \ / | | | | | | | \| | | |__ | | | | _
\ \ \ \ / / | |__| | | |__| | / ____ \ | | \ \ \ \ / / | |__| | | |__| | / ____ \ | | | |____ | |__| | | . ` | | __| _| |_ | |__| |
\ \/ \ \/ / \_ ___/ \____/ /_/ \_\ |_| \ \/ \ \/ / \_ ___/ \____/ /_/ \_\ |_| \_____| \____/ |_| \_| |_| |_____| \_____|
\__/ \__/ \ \__ \__/ \__/ \ \__
\___\ by CoreOS \___\ by CoreOS

View file

@ -12,7 +12,10 @@
"watch": "npm run clean && webpack --watch", "watch": "npm run clean && webpack --watch",
"lint": "tslint --type-check -p tsconfig.json -e **/*.spec.ts", "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", "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": { "repository": {
"type": "git", "type": "git",

View file

@ -1,5 +0,0 @@
const setupPage = '';
angular.module('quay', [setupPage]);
console.log('Hello world! I\'m the config app');

8
web.py
View file

@ -11,15 +11,7 @@ from endpoints.webhooks import webhooks
from endpoints.wellknown import wellknown 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(web)
application.register_blueprint(githubtrigger, url_prefix='/oauth2') application.register_blueprint(githubtrigger, url_prefix='/oauth2')
application.register_blueprint(gitlabtrigger, url_prefix='/oauth2') application.register_blueprint(gitlabtrigger, url_prefix='/oauth2')
application.register_blueprint(oauthlogin, url_prefix='/oauth2') application.register_blueprint(oauthlogin, url_prefix='/oauth2')

View file

@ -3,10 +3,7 @@ const path = require('path');
let config = { let config = {
entry: { entry: "./static/js/main.ts",
main: "./static/js/main.ts",
configapp: "./static/configappjs/index.js"
},
output: { output: {
path: path.resolve(__dirname, "static/build"), path: path.resolve(__dirname, "static/build"),
publicPath: "/static/build/", publicPath: "/static/build/",