Merge pull request #3104 from quay/project/app-endpoints
Config App initial entrypoint
This commit is contained in:
commit
093a58558c
79 changed files with 9028 additions and 11 deletions
|
@ -14,6 +14,7 @@ before_script:
|
||||||
- sudo service postgresql stop
|
- sudo service postgresql stop
|
||||||
|
|
||||||
cache:
|
cache:
|
||||||
|
timeout: 1000
|
||||||
directories:
|
directories:
|
||||||
- $HOME/docker
|
- $HOME/docker
|
||||||
|
|
||||||
|
|
|
@ -106,6 +106,10 @@ RUN yarn build \
|
||||||
&& jpegoptim static/img/**/*.jpg \
|
&& jpegoptim static/img/**/*.jpg \
|
||||||
&& optipng -clobber -quiet static/img/**/*.png
|
&& optipng -clobber -quiet static/img/**/*.png
|
||||||
|
|
||||||
|
# Config app js compile
|
||||||
|
COPY config_app/ config_app/
|
||||||
|
RUN yarn build-config-app
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
RUN PYTHONPATH=$QUAYPATH venv/bin/python -m external_libraries
|
RUN PYTHONPATH=$QUAYPATH venv/bin/python -m external_libraries
|
||||||
|
@ -138,4 +142,4 @@ RUN ./scripts/detect-config.sh
|
||||||
|
|
||||||
EXPOSE 443 8443 80
|
EXPOSE 443 8443 80
|
||||||
|
|
||||||
CMD ./quay-entrypoint.sh
|
ENTRYPOINT [ "/bin/bash", "./quay-entrypoint.sh"]
|
||||||
|
|
3
config_app/Procfile
Normal file
3
config_app/Procfile
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
app: PYTHONPATH="../" gunicorn -c conf/gunicorn_local.py config_application:application
|
||||||
|
webpack: npm run watch-config-app
|
||||||
|
|
0
config_app/__init__.py
Normal file
0
config_app/__init__.py
Normal file
39
config_app/_init_config.py
Normal file
39
config_app/_init_config.py
Normal file
|
@ -0,0 +1,39 @@
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
|
||||||
|
# Note: this currently points to the directory above, since we're in the quay config_app dir
|
||||||
|
# TODO(config_extract): revert to root directory rather than the one above
|
||||||
|
ROOT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
|
||||||
|
CONF_DIR = os.getenv("QUAYCONF", os.path.join(ROOT_DIR, "conf/"))
|
||||||
|
STATIC_DIR = os.path.join(ROOT_DIR, 'static/')
|
||||||
|
STATIC_LDN_DIR = os.path.join(STATIC_DIR, 'ldn/')
|
||||||
|
STATIC_FONTS_DIR = os.path.join(STATIC_DIR, 'fonts/')
|
||||||
|
TEMPLATE_DIR = os.path.join(ROOT_DIR, 'templates/')
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def _get_version_number_changelog():
|
||||||
|
try:
|
||||||
|
with open(os.path.join(ROOT_DIR, 'CHANGELOG.md')) as f:
|
||||||
|
return re.search(r'(v[0-9]+\.[0-9]+\.[0-9]+)', f.readline()).group(0)
|
||||||
|
except IOError:
|
||||||
|
return ''
|
||||||
|
|
||||||
|
|
||||||
|
def _get_git_sha():
|
||||||
|
if os.path.exists("GIT_HEAD"):
|
||||||
|
with open(os.path.join(ROOT_DIR, "GIT_HEAD")) as f:
|
||||||
|
return f.read()
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
return subprocess.check_output(["git", "rev-parse", "HEAD"]).strip()[0:8]
|
||||||
|
except (OSError, subprocess.CalledProcessError):
|
||||||
|
pass
|
||||||
|
return "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
__version__ = _get_version_number_changelog()
|
||||||
|
__gitrev__ = _get_git_sha()
|
37
config_app/c_app.py
Normal file
37
config_app/c_app.py
Normal file
|
@ -0,0 +1,37 @@
|
||||||
|
import os
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from flask import Flask
|
||||||
|
|
||||||
|
from data import database
|
||||||
|
from util.config.superusermanager import SuperUserManager
|
||||||
|
from util.ipresolver import NoopIPResolver
|
||||||
|
|
||||||
|
from config_app._init_config import ROOT_DIR
|
||||||
|
from config_app.config_util.config import get_config_provider
|
||||||
|
|
||||||
|
app = Flask(__name__)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
OVERRIDE_CONFIG_DIRECTORY = os.path.join(ROOT_DIR, 'config_app/conf/stack')
|
||||||
|
|
||||||
|
is_testing = 'TEST' in os.environ
|
||||||
|
|
||||||
|
config_provider = get_config_provider(OVERRIDE_CONFIG_DIRECTORY, 'config.yaml', 'config.py',
|
||||||
|
testing=is_testing)
|
||||||
|
|
||||||
|
if is_testing:
|
||||||
|
from test.testconfig import TestConfig
|
||||||
|
logger.debug('Loading test config.')
|
||||||
|
app.config.from_object(TestConfig())
|
||||||
|
else:
|
||||||
|
from config import DefaultConfig
|
||||||
|
logger.debug('Loading default config.')
|
||||||
|
app.config.from_object(DefaultConfig())
|
||||||
|
app.teardown_request(database.close_db_filter)
|
||||||
|
|
||||||
|
# Load the override config via the provider.
|
||||||
|
config_provider.update_app_config(app.config)
|
||||||
|
superusers = SuperUserManager(app)
|
||||||
|
ip_resolver = NoopIPResolver()
|
0
config_app/conf/__init__.py
Normal file
0
config_app/conf/__init__.py
Normal file
26
config_app/conf/gunicorn_local.py
Normal file
26
config_app/conf/gunicorn_local.py
Normal file
|
@ -0,0 +1,26 @@
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
sys.path.append(os.path.join(os.path.dirname(__file__), "../"))
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from Crypto import Random
|
||||||
|
from config_app.config_util.log import logfile_path
|
||||||
|
|
||||||
|
|
||||||
|
logconfig = logfile_path(debug=True)
|
||||||
|
bind = '0.0.0.0:5000'
|
||||||
|
workers = 1
|
||||||
|
worker_class = 'gevent'
|
||||||
|
daemon = False
|
||||||
|
pythonpath = '.'
|
||||||
|
preload_app = True
|
||||||
|
|
||||||
|
def post_fork(server, worker):
|
||||||
|
# Reset the Random library to ensure it won't raise the "PID check failed." error after
|
||||||
|
# gunicorn forks.
|
||||||
|
Random.atfork()
|
||||||
|
|
||||||
|
def when_ready(server):
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
logger.debug('Starting local gunicorn with %s workers and %s worker class', workers, worker_class)
|
26
config_app/conf/gunicorn_web.py
Normal file
26
config_app/conf/gunicorn_web.py
Normal file
|
@ -0,0 +1,26 @@
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
sys.path.append(os.path.join(os.path.dirname(__file__), "../"))
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from Crypto import Random
|
||||||
|
from config_app.config_util.log import logfile_path
|
||||||
|
|
||||||
|
|
||||||
|
logconfig = logfile_path(debug=True)
|
||||||
|
|
||||||
|
bind = '0.0.0.0:80'
|
||||||
|
workers = 1
|
||||||
|
worker_class = 'gevent'
|
||||||
|
pythonpath = '.'
|
||||||
|
preload_app = True
|
||||||
|
|
||||||
|
def post_fork(server, worker):
|
||||||
|
# Reset the Random library to ensure it won't raise the "PID check failed." error after
|
||||||
|
# gunicorn forks.
|
||||||
|
Random.atfork()
|
||||||
|
|
||||||
|
def when_ready(server):
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
logger.debug('Starting local gunicorn with %s workers and %s worker class', workers, worker_class)
|
33
config_app/conf/logging.conf
Normal file
33
config_app/conf/logging.conf
Normal file
|
@ -0,0 +1,33 @@
|
||||||
|
[loggers]
|
||||||
|
keys=root,gunicorn.error,gunicorn.access
|
||||||
|
|
||||||
|
[handlers]
|
||||||
|
keys=console
|
||||||
|
|
||||||
|
[formatters]
|
||||||
|
keys=generic,json
|
||||||
|
|
||||||
|
[logger_root]
|
||||||
|
level=INFO
|
||||||
|
handlers=console
|
||||||
|
|
||||||
|
[handler_console]
|
||||||
|
class=StreamHandler
|
||||||
|
formatter=generic
|
||||||
|
args=(sys.stdout, )
|
||||||
|
|
||||||
|
[formatter_generic]
|
||||||
|
format=%(asctime)s [%(process)d] [%(levelname)s] [%(name)s] %(message)s
|
||||||
|
class=logging.Formatter
|
||||||
|
|
||||||
|
[logger_gunicorn.error]
|
||||||
|
level=ERROR
|
||||||
|
handlers=console
|
||||||
|
propagate=0
|
||||||
|
qualname=gunicorn.error
|
||||||
|
|
||||||
|
[logger_gunicorn.access]
|
||||||
|
handlers=console
|
||||||
|
propagate=0
|
||||||
|
qualname=gunicorn.access
|
||||||
|
level=DEBUG
|
38
config_app/conf/logging_debug.conf
Normal file
38
config_app/conf/logging_debug.conf
Normal file
|
@ -0,0 +1,38 @@
|
||||||
|
[loggers]
|
||||||
|
keys=root,boto,gunicorn.error,gunicorn.access
|
||||||
|
|
||||||
|
[handlers]
|
||||||
|
keys=console
|
||||||
|
|
||||||
|
[formatters]
|
||||||
|
keys=generic,json
|
||||||
|
|
||||||
|
[logger_root]
|
||||||
|
level=DEBUG
|
||||||
|
handlers=console
|
||||||
|
|
||||||
|
[logger_boto]
|
||||||
|
level=INFO
|
||||||
|
handlers=console
|
||||||
|
qualname=boto
|
||||||
|
|
||||||
|
[logger_gunicorn.access]
|
||||||
|
handlers=console
|
||||||
|
propagate=0
|
||||||
|
qualname=gunicorn.access
|
||||||
|
level=DEBUG
|
||||||
|
|
||||||
|
[handler_console]
|
||||||
|
class=StreamHandler
|
||||||
|
formatter=generic
|
||||||
|
args=(sys.stdout, )
|
||||||
|
|
||||||
|
[logger_gunicorn.error]
|
||||||
|
level=ERROR
|
||||||
|
handlers=console
|
||||||
|
propagate=0
|
||||||
|
qualname=gunicorn.error
|
||||||
|
|
||||||
|
[formatter_generic]
|
||||||
|
format=%(asctime)s [%(process)d] [%(levelname)s] [%(name)s] %(message)s
|
||||||
|
class=logging.Formatter
|
38
config_app/conf/logging_debug_json.conf
Normal file
38
config_app/conf/logging_debug_json.conf
Normal file
|
@ -0,0 +1,38 @@
|
||||||
|
[loggers]
|
||||||
|
keys=root,boto,gunicorn.error,gunicorn.access
|
||||||
|
|
||||||
|
[handlers]
|
||||||
|
keys=console
|
||||||
|
|
||||||
|
[formatters]
|
||||||
|
keys=generic,json
|
||||||
|
|
||||||
|
[logger_root]
|
||||||
|
level=DEBUG
|
||||||
|
handlers=console
|
||||||
|
|
||||||
|
[logger_boto]
|
||||||
|
level=INFO
|
||||||
|
handlers=console
|
||||||
|
qualname=boto
|
||||||
|
|
||||||
|
[logger_gunicorn.access]
|
||||||
|
handlers=console
|
||||||
|
propagate=0
|
||||||
|
qualname=gunicorn.access
|
||||||
|
level=DEBUG
|
||||||
|
|
||||||
|
[handler_console]
|
||||||
|
class=StreamHandler
|
||||||
|
formatter=json
|
||||||
|
args=(sys.stdout, )
|
||||||
|
|
||||||
|
[logger_gunicorn.error]
|
||||||
|
level=ERROR
|
||||||
|
handlers=console
|
||||||
|
propagate=0
|
||||||
|
qualname=gunicorn.error
|
||||||
|
|
||||||
|
[formatter_generic]
|
||||||
|
format=%(asctime)s [%(process)d] [%(levelname)s] [%(name)s] %(message)s
|
||||||
|
class=logging.Formatter
|
33
config_app/conf/logging_json.conf
Normal file
33
config_app/conf/logging_json.conf
Normal file
|
@ -0,0 +1,33 @@
|
||||||
|
[loggers]
|
||||||
|
keys=root,gunicorn.error,gunicorn.access
|
||||||
|
|
||||||
|
[handlers]
|
||||||
|
keys=console
|
||||||
|
|
||||||
|
[formatters]
|
||||||
|
keys=json,generic
|
||||||
|
|
||||||
|
[logger_root]
|
||||||
|
level=INFO
|
||||||
|
handlers=console
|
||||||
|
|
||||||
|
[handler_console]
|
||||||
|
class=StreamHandler
|
||||||
|
formatter=json
|
||||||
|
args=(sys.stdout, )
|
||||||
|
|
||||||
|
[formatter_generic]
|
||||||
|
format=%(asctime)s [%(process)d] [%(levelname)s] [%(name)s] %(message)s
|
||||||
|
class=logging.Formatter
|
||||||
|
|
||||||
|
[logger_gunicorn.error]
|
||||||
|
level=ERROR
|
||||||
|
handlers=console
|
||||||
|
propagate=0
|
||||||
|
qualname=gunicorn.error
|
||||||
|
|
||||||
|
[logger_gunicorn.access]
|
||||||
|
handlers=console
|
||||||
|
propagate=0
|
||||||
|
qualname=gunicorn.access
|
||||||
|
level=DEBUG
|
9
config_app/config_application.py
Normal file
9
config_app/config_application.py
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
from config_app.c_app import app as application
|
||||||
|
|
||||||
|
# Bind all of the blueprints
|
||||||
|
import config_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')
|
0
config_app/config_endpoints/__init__.py
Normal file
0
config_app/config_endpoints/__init__.py
Normal file
140
config_app/config_endpoints/api/__init__.py
Normal file
140
config_app/config_endpoints/api/__init__.py
Normal file
|
@ -0,0 +1,140 @@
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from flask import Blueprint, request
|
||||||
|
from flask_restful import Resource, Api
|
||||||
|
from flask_restful.utils.cors import crossdomain
|
||||||
|
from email.utils import formatdate
|
||||||
|
from calendar import timegm
|
||||||
|
from functools import partial, wraps
|
||||||
|
from jsonschema import validate, ValidationError
|
||||||
|
|
||||||
|
from config_app.c_app import app
|
||||||
|
from config_app.config_endpoints.exception import InvalidResponse, InvalidRequest
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
api_bp = Blueprint('api', __name__)
|
||||||
|
|
||||||
|
CROSS_DOMAIN_HEADERS = ['Authorization', 'Content-Type', 'X-Requested-With']
|
||||||
|
|
||||||
|
|
||||||
|
class ApiExceptionHandlingApi(Api):
|
||||||
|
pass
|
||||||
|
|
||||||
|
@crossdomain(origin='*', headers=CROSS_DOMAIN_HEADERS)
|
||||||
|
def handle_error(self, error):
|
||||||
|
return super(ApiExceptionHandlingApi, self).handle_error(error)
|
||||||
|
|
||||||
|
|
||||||
|
api = ApiExceptionHandlingApi()
|
||||||
|
|
||||||
|
api.init_app(api_bp)
|
||||||
|
|
||||||
|
|
||||||
|
def format_date(date):
|
||||||
|
""" Output an RFC822 date format. """
|
||||||
|
if date is None:
|
||||||
|
return None
|
||||||
|
return formatdate(timegm(date.utctimetuple()))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
def define_json_response(schema_name):
|
||||||
|
def wrapper(func):
|
||||||
|
@add_method_metadata('response_schema', schema_name)
|
||||||
|
@wraps(func)
|
||||||
|
def wrapped(self, *args, **kwargs):
|
||||||
|
schema = self.schemas[schema_name]
|
||||||
|
resp = func(self, *args, **kwargs)
|
||||||
|
|
||||||
|
if app.config['TESTING']:
|
||||||
|
try:
|
||||||
|
validate(resp, schema)
|
||||||
|
except ValidationError as ex:
|
||||||
|
raise InvalidResponse(ex.message)
|
||||||
|
|
||||||
|
return resp
|
||||||
|
return wrapped
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
|
def validate_json_request(schema_name, optional=False):
|
||||||
|
def wrapper(func):
|
||||||
|
@add_method_metadata('request_schema', schema_name)
|
||||||
|
@wraps(func)
|
||||||
|
def wrapped(self, *args, **kwargs):
|
||||||
|
schema = self.schemas[schema_name]
|
||||||
|
try:
|
||||||
|
json_data = request.get_json()
|
||||||
|
if json_data is None:
|
||||||
|
if not optional:
|
||||||
|
raise InvalidRequest('Missing JSON body')
|
||||||
|
else:
|
||||||
|
validate(json_data, schema)
|
||||||
|
return func(self, *args, **kwargs)
|
||||||
|
except ValidationError as ex:
|
||||||
|
raise InvalidRequest(ex.message)
|
||||||
|
return wrapped
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
|
nickname = partial(add_method_metadata, 'nickname')
|
||||||
|
|
||||||
|
|
||||||
|
import config_endpoints.api
|
||||||
|
import config_endpoints.api.discovery
|
||||||
|
import config_endpoints.api.suconfig
|
||||||
|
import config_endpoints.api.superuser
|
||||||
|
import config_endpoints.api.user
|
||||||
|
|
253
config_app/config_endpoints/api/discovery.py
Normal file
253
config_app/config_endpoints/api/discovery.py
Normal file
|
@ -0,0 +1,253 @@
|
||||||
|
# TODO to extract the discovery stuff into a util at the top level and then use it both here and old discovery.py
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
from collections import OrderedDict
|
||||||
|
|
||||||
|
from config_app.c_app import app
|
||||||
|
from config_app.config_endpoints.api import method_metadata
|
||||||
|
from config_app.config_endpoints.common import fully_qualified_name, PARAM_REGEX, TYPE_CONVERTER
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
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()
|
||||||
|
|
||||||
|
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}
|
309
config_app/config_endpoints/api/suconfig.py
Normal file
309
config_app/config_endpoints/api/suconfig.py
Normal file
|
@ -0,0 +1,309 @@
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import signal
|
||||||
|
|
||||||
|
from flask import abort, request
|
||||||
|
|
||||||
|
from config_app.config_endpoints.api.suconfig_models_pre_oci import pre_oci_model as model
|
||||||
|
from config_app.config_endpoints.api import resource, ApiResource, nickname, validate_json_request
|
||||||
|
from config_app.c_app import app, config_provider, superusers, OVERRIDE_CONFIG_DIRECTORY, ip_resolver
|
||||||
|
|
||||||
|
from auth.auth_context import get_authenticated_user
|
||||||
|
from data.users import get_federated_service_name, get_users_handler
|
||||||
|
from data.database import configure
|
||||||
|
from data.runmigration import run_alembic_migration
|
||||||
|
from util.config.configutil import add_enterprise_config_defaults
|
||||||
|
from util.config.database import sync_database_with_config
|
||||||
|
from util.config.validator import validate_service_for_config, ValidatorContext
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def database_is_valid():
|
||||||
|
""" Returns whether the database, as configured, is valid. """
|
||||||
|
if app.config['TESTING']:
|
||||||
|
return False
|
||||||
|
|
||||||
|
return model.is_valid()
|
||||||
|
|
||||||
|
|
||||||
|
def database_has_users():
|
||||||
|
""" Returns whether the database has any users defined. """
|
||||||
|
return model.has_users()
|
||||||
|
|
||||||
|
|
||||||
|
@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'
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
@nickname('scGetConfig')
|
||||||
|
def get(self):
|
||||||
|
""" Returns the currently defined configuration, if any. """
|
||||||
|
config_object = config_provider.get_config()
|
||||||
|
return {
|
||||||
|
'config': config_object
|
||||||
|
}
|
||||||
|
|
||||||
|
@nickname('scUpdateConfig')
|
||||||
|
@validate_json_request('UpdateConfig')
|
||||||
|
def put(self):
|
||||||
|
""" Updates the config override file. """
|
||||||
|
# Note: This method is called to set the database configuration before super users exists,
|
||||||
|
# so we also allow it to be called if there is no valid registry configuration setup.
|
||||||
|
if not config_provider.config_exists():
|
||||||
|
config_object = request.get_json()['config']
|
||||||
|
hostname = request.get_json()['hostname']
|
||||||
|
|
||||||
|
# Add any enterprise defaults missing from the config.
|
||||||
|
add_enterprise_config_defaults(config_object, app.config['SECRET_KEY'], hostname)
|
||||||
|
|
||||||
|
# Write the configuration changes to the config override file.
|
||||||
|
config_provider.save_config(config_object)
|
||||||
|
|
||||||
|
# If the authentication system is federated, link the superuser account to the
|
||||||
|
# the authentication system chosen.
|
||||||
|
service_name = get_federated_service_name(config_object['AUTHENTICATION_TYPE'])
|
||||||
|
if service_name is not None:
|
||||||
|
current_user = get_authenticated_user()
|
||||||
|
if current_user is None:
|
||||||
|
abort(401)
|
||||||
|
|
||||||
|
service_name = get_federated_service_name(config_object['AUTHENTICATION_TYPE'])
|
||||||
|
if not model.has_federated_login(current_user.username, service_name):
|
||||||
|
# Verify the user's credentials and retrieve the user's external username+email.
|
||||||
|
handler = get_users_handler(config_object, config_provider, OVERRIDE_CONFIG_DIRECTORY)
|
||||||
|
(result, err_msg) = handler.verify_credentials(current_user.username,
|
||||||
|
request.get_json().get('password', ''))
|
||||||
|
if not result:
|
||||||
|
logger.error('Could not save configuration due to external auth failure: %s', err_msg)
|
||||||
|
abort(400)
|
||||||
|
|
||||||
|
# Link the existing user to the external user.
|
||||||
|
model.attach_federated_login(current_user.username, service_name, result.username)
|
||||||
|
|
||||||
|
# Ensure database is up-to-date with config
|
||||||
|
sync_database_with_config(config_object)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'exists': True,
|
||||||
|
'config': config_object
|
||||||
|
}
|
||||||
|
|
||||||
|
abort(403)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@resource('/v1/superuser/registrystatus')
|
||||||
|
class SuperUserRegistryStatus(ApiResource):
|
||||||
|
""" Resource for determining the status of the registry, such as if config exists,
|
||||||
|
if a database is configured, and if it has any defined users.
|
||||||
|
"""
|
||||||
|
@nickname('scRegistryStatus')
|
||||||
|
def get(self):
|
||||||
|
""" Returns the status of the registry. """
|
||||||
|
|
||||||
|
# If we have SETUP_COMPLETE, then we're ready to go!
|
||||||
|
if app.config.get('SETUP_COMPLETE', False):
|
||||||
|
return {
|
||||||
|
'provider_id': config_provider.provider_id,
|
||||||
|
'requires_restart': config_provider.requires_restart(app.config),
|
||||||
|
'status': 'ready'
|
||||||
|
}
|
||||||
|
|
||||||
|
# If there is no conf/stack volume, then report that status.
|
||||||
|
if not config_provider.volume_exists():
|
||||||
|
return {
|
||||||
|
'status': 'missing-config-dir'
|
||||||
|
}
|
||||||
|
|
||||||
|
# If there is no config file, we need to setup the database.
|
||||||
|
if not config_provider.config_exists():
|
||||||
|
return {
|
||||||
|
'status': 'config-db'
|
||||||
|
}
|
||||||
|
|
||||||
|
# If the database isn't yet valid, then we need to set it up.
|
||||||
|
if not database_is_valid():
|
||||||
|
return {
|
||||||
|
'status': 'setup-db'
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
'status': 'create-superuser' if not database_has_users() else 'config'
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class _AlembicLogHandler(logging.Handler):
|
||||||
|
def __init__(self):
|
||||||
|
super(_AlembicLogHandler, self).__init__()
|
||||||
|
self.records = []
|
||||||
|
|
||||||
|
def emit(self, record):
|
||||||
|
self.records.append({
|
||||||
|
'level': record.levelname,
|
||||||
|
'message': record.getMessage()
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@resource('/v1/superuser/setupdb')
|
||||||
|
class SuperUserSetupDatabase(ApiResource):
|
||||||
|
""" Resource for invoking alembic to setup the database. """
|
||||||
|
@nickname('scSetupDatabase')
|
||||||
|
def get(self):
|
||||||
|
""" Invokes the alembic upgrade process. """
|
||||||
|
# Note: This method is called after the database configured is saved, but before the
|
||||||
|
# database has any tables. Therefore, we only allow it to be run in that unique case.
|
||||||
|
if config_provider.config_exists() and not database_is_valid():
|
||||||
|
# Note: We need to reconfigure the database here as the config has changed.
|
||||||
|
combined = dict(**app.config)
|
||||||
|
combined.update(config_provider.get_config())
|
||||||
|
|
||||||
|
configure(combined)
|
||||||
|
app.config['DB_URI'] = combined['DB_URI']
|
||||||
|
|
||||||
|
log_handler = _AlembicLogHandler()
|
||||||
|
|
||||||
|
try:
|
||||||
|
run_alembic_migration(log_handler)
|
||||||
|
except Exception as ex:
|
||||||
|
return {
|
||||||
|
'error': str(ex)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
'logs': log_handler.records
|
||||||
|
}
|
||||||
|
|
||||||
|
abort(403)
|
||||||
|
|
||||||
|
|
||||||
|
@resource('/v1/superuser/config/createsuperuser')
|
||||||
|
class SuperUserCreateInitialSuperUser(ApiResource):
|
||||||
|
""" Resource for creating the initial super user. """
|
||||||
|
schemas = {
|
||||||
|
'CreateSuperUser': {
|
||||||
|
'type': 'object',
|
||||||
|
'description': 'Information for creating the initial super user',
|
||||||
|
'required': [
|
||||||
|
'username',
|
||||||
|
'password',
|
||||||
|
'email'
|
||||||
|
],
|
||||||
|
'properties': {
|
||||||
|
'username': {
|
||||||
|
'type': 'string',
|
||||||
|
'description': 'The username for the superuser'
|
||||||
|
},
|
||||||
|
'password': {
|
||||||
|
'type': 'string',
|
||||||
|
'description': 'The password for the superuser'
|
||||||
|
},
|
||||||
|
'email': {
|
||||||
|
'type': 'string',
|
||||||
|
'description': 'The e-mail address for the superuser'
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
@nickname('scCreateInitialSuperuser')
|
||||||
|
@validate_json_request('CreateSuperUser')
|
||||||
|
def post(self):
|
||||||
|
""" Creates the initial super user, updates the underlying configuration and
|
||||||
|
sets the current session to have that super user. """
|
||||||
|
|
||||||
|
# Special security check: This method is only accessible when:
|
||||||
|
# - There is a valid config YAML file.
|
||||||
|
# - There are currently no users in the database (clean install)
|
||||||
|
#
|
||||||
|
# We do this special security check because at the point this method is called, the database
|
||||||
|
# is clean but does not (yet) have any super users for our permissions code to check against.
|
||||||
|
if config_provider.config_exists() and not database_has_users():
|
||||||
|
data = request.get_json()
|
||||||
|
username = data['username']
|
||||||
|
password = data['password']
|
||||||
|
email = data['email']
|
||||||
|
|
||||||
|
# Create the user in the database.
|
||||||
|
superuser_uuid = model.create_superuser(username, password, email)
|
||||||
|
|
||||||
|
# Add the user to the config.
|
||||||
|
config_object = config_provider.get_config()
|
||||||
|
config_object['SUPER_USERS'] = [username]
|
||||||
|
config_provider.save_config(config_object)
|
||||||
|
|
||||||
|
# Update the in-memory config for the new superuser.
|
||||||
|
# TODO(config): do we need to register a list of the superusers? If so, we can take out the entire superuser in c_app
|
||||||
|
superusers.register_superuser(username)
|
||||||
|
|
||||||
|
# Conduct login with that user.
|
||||||
|
# TODO(config): figure out if we need validation for checking logged in user stuff
|
||||||
|
# common_login(superuser_uuid)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'status': True
|
||||||
|
}
|
||||||
|
|
||||||
|
abort(403)
|
||||||
|
|
||||||
|
|
||||||
|
@resource('/v1/superuser/config/validate/<service>')
|
||||||
|
class SuperUserConfigValidate(ApiResource):
|
||||||
|
""" Resource for validating a block of configuration against an external service. """
|
||||||
|
schemas = {
|
||||||
|
'ValidateConfig': {
|
||||||
|
'type': 'object',
|
||||||
|
'description': 'Validates configuration',
|
||||||
|
'required': [
|
||||||
|
'config'
|
||||||
|
],
|
||||||
|
'properties': {
|
||||||
|
'config': {
|
||||||
|
'type': 'object'
|
||||||
|
},
|
||||||
|
'password': {
|
||||||
|
'type': 'string',
|
||||||
|
'description': 'The users password, used for auth validation'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
@nickname('scValidateConfig')
|
||||||
|
@validate_json_request('ValidateConfig')
|
||||||
|
def post(self, service):
|
||||||
|
""" Validates the given config for the given service. """
|
||||||
|
# Note: This method is called to validate the database configuration before super users exists,
|
||||||
|
# so we also allow it to be called if there is no valid registry configuration setup. Note that
|
||||||
|
# this is also safe since this method does not access any information not given in the request.
|
||||||
|
if not config_provider.config_exists():
|
||||||
|
config = request.get_json()['config']
|
||||||
|
validator_context = ValidatorContext.from_app(app, config, request.get_json().get('password', ''),
|
||||||
|
ip_resolver=ip_resolver,
|
||||||
|
config_provider=config_provider)
|
||||||
|
return validate_service_for_config(service, validator_context)
|
||||||
|
|
||||||
|
|
||||||
|
abort(403)
|
39
config_app/config_endpoints/api/suconfig_models_interface.py
Normal file
39
config_app/config_endpoints/api/suconfig_models_interface.py
Normal file
|
@ -0,0 +1,39 @@
|
||||||
|
from abc import ABCMeta, abstractmethod
|
||||||
|
from six import add_metaclass
|
||||||
|
|
||||||
|
|
||||||
|
@add_metaclass(ABCMeta)
|
||||||
|
class SuperuserConfigDataInterface(object):
|
||||||
|
"""
|
||||||
|
Interface that represents all data store interactions required by the superuser config API.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def is_valid(self):
|
||||||
|
"""
|
||||||
|
Returns true if the configured database is valid.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def has_users(self):
|
||||||
|
"""
|
||||||
|
Returns true if there are any users defined.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def create_superuser(self, username, password, email):
|
||||||
|
"""
|
||||||
|
Creates a new superuser with the given username, password and email. Returns the user's UUID.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def has_federated_login(self, username, service_name):
|
||||||
|
"""
|
||||||
|
Returns true if the matching user has a federated login under the matching service.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def attach_federated_login(self, username, service_name, federated_username):
|
||||||
|
"""
|
||||||
|
Attaches a federatated login to the matching user, under the given service.
|
||||||
|
"""
|
35
config_app/config_endpoints/api/suconfig_models_pre_oci.py
Normal file
35
config_app/config_endpoints/api/suconfig_models_pre_oci.py
Normal file
|
@ -0,0 +1,35 @@
|
||||||
|
from data import model
|
||||||
|
from data.database import User
|
||||||
|
from config_app.config_endpoints.api.suconfig_models_interface import SuperuserConfigDataInterface
|
||||||
|
|
||||||
|
|
||||||
|
class PreOCIModel(SuperuserConfigDataInterface):
|
||||||
|
def is_valid(self):
|
||||||
|
try:
|
||||||
|
list(User.select().limit(1))
|
||||||
|
return True
|
||||||
|
except:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def has_users(self):
|
||||||
|
return bool(list(User.select().limit(1)))
|
||||||
|
|
||||||
|
def create_superuser(self, username, password, email):
|
||||||
|
return model.user.create_user(username, password, email, auto_verify=True).uuid
|
||||||
|
|
||||||
|
def has_federated_login(self, username, service_name):
|
||||||
|
user = model.user.get_user(username)
|
||||||
|
if user is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
return bool(model.user.lookup_federated_login(user, service_name))
|
||||||
|
|
||||||
|
def attach_federated_login(self, username, service_name, federated_username):
|
||||||
|
user = model.user.get_user(username)
|
||||||
|
if user is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
model.user.attach_federated_login(user, service_name, federated_username)
|
||||||
|
|
||||||
|
|
||||||
|
pre_oci_model = PreOCIModel()
|
149
config_app/config_endpoints/api/superuser.py
Normal file
149
config_app/config_endpoints/api/superuser.py
Normal file
|
@ -0,0 +1,149 @@
|
||||||
|
import os
|
||||||
|
import logging
|
||||||
|
import pathvalidate
|
||||||
|
from flask import request, jsonify
|
||||||
|
|
||||||
|
from config_app.config_endpoints.exception import InvalidRequest
|
||||||
|
from config_app.config_endpoints.api import resource, ApiResource, nickname
|
||||||
|
from config_app.config_util.ssl import load_certificate, CertInvalidException
|
||||||
|
from config_app.c_app import app, config_provider
|
||||||
|
|
||||||
|
from config_app.config_endpoints.api.superuser_models_pre_oci import pre_oci_model
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
EXTRA_CA_DIRECTORY = 'extra_ca_certs'
|
||||||
|
|
||||||
|
|
||||||
|
@resource('/v1/superuser/customcerts/<certpath>')
|
||||||
|
class SuperUserCustomCertificate(ApiResource):
|
||||||
|
""" Resource for managing a custom certificate. """
|
||||||
|
|
||||||
|
@nickname('uploadCustomCertificate')
|
||||||
|
def post(self, certpath):
|
||||||
|
uploaded_file = request.files['file']
|
||||||
|
if not uploaded_file:
|
||||||
|
raise InvalidRequest('Missing certificate file')
|
||||||
|
|
||||||
|
# Save the certificate.
|
||||||
|
certpath = pathvalidate.sanitize_filename(certpath)
|
||||||
|
if not certpath.endswith('.crt'):
|
||||||
|
raise InvalidRequest('Invalid certificate file: must have suffix `.crt`')
|
||||||
|
|
||||||
|
logger.debug('Saving custom certificate %s', certpath)
|
||||||
|
cert_full_path = config_provider.get_volume_path(EXTRA_CA_DIRECTORY, certpath)
|
||||||
|
config_provider.save_volume_file(cert_full_path, uploaded_file)
|
||||||
|
logger.debug('Saved custom certificate %s', certpath)
|
||||||
|
|
||||||
|
# Validate the certificate.
|
||||||
|
try:
|
||||||
|
logger.debug('Loading custom certificate %s', certpath)
|
||||||
|
with config_provider.get_volume_file(cert_full_path) as f:
|
||||||
|
load_certificate(f.read())
|
||||||
|
except CertInvalidException:
|
||||||
|
logger.exception('Got certificate invalid error for cert %s', certpath)
|
||||||
|
return '', 204
|
||||||
|
except IOError:
|
||||||
|
logger.exception('Got IO error for cert %s', certpath)
|
||||||
|
return '', 204
|
||||||
|
|
||||||
|
# Call the update script to install the certificate immediately.
|
||||||
|
if not app.config['TESTING']:
|
||||||
|
logger.debug('Calling certs_install.sh')
|
||||||
|
if os.system('/conf/init/certs_install.sh') != 0:
|
||||||
|
raise Exception('Could not install certificates')
|
||||||
|
|
||||||
|
logger.debug('certs_install.sh completed')
|
||||||
|
|
||||||
|
return '', 204
|
||||||
|
|
||||||
|
@nickname('deleteCustomCertificate')
|
||||||
|
def delete(self, certpath):
|
||||||
|
cert_full_path = config_provider.get_volume_path(EXTRA_CA_DIRECTORY, certpath)
|
||||||
|
config_provider.remove_volume_file(cert_full_path)
|
||||||
|
return '', 204
|
||||||
|
|
||||||
|
|
||||||
|
@resource('/v1/superuser/customcerts')
|
||||||
|
class SuperUserCustomCertificates(ApiResource):
|
||||||
|
""" Resource for managing custom certificates. """
|
||||||
|
|
||||||
|
@nickname('getCustomCertificates')
|
||||||
|
def get(self):
|
||||||
|
has_extra_certs_path = config_provider.volume_file_exists(EXTRA_CA_DIRECTORY)
|
||||||
|
extra_certs_found = config_provider.list_volume_directory(EXTRA_CA_DIRECTORY)
|
||||||
|
if extra_certs_found is None:
|
||||||
|
return {
|
||||||
|
'status': 'file' if has_extra_certs_path else 'none',
|
||||||
|
}
|
||||||
|
|
||||||
|
cert_views = []
|
||||||
|
for extra_cert_path in extra_certs_found:
|
||||||
|
try:
|
||||||
|
cert_full_path = config_provider.get_volume_path(EXTRA_CA_DIRECTORY, extra_cert_path)
|
||||||
|
with config_provider.get_volume_file(cert_full_path) as f:
|
||||||
|
certificate = load_certificate(f.read())
|
||||||
|
cert_views.append({
|
||||||
|
'path': extra_cert_path,
|
||||||
|
'names': list(certificate.names),
|
||||||
|
'expired': certificate.expired,
|
||||||
|
})
|
||||||
|
except CertInvalidException as cie:
|
||||||
|
cert_views.append({
|
||||||
|
'path': extra_cert_path,
|
||||||
|
'error': cie.message,
|
||||||
|
})
|
||||||
|
except IOError as ioe:
|
||||||
|
cert_views.append({
|
||||||
|
'path': extra_cert_path,
|
||||||
|
'error': ioe.message,
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
'status': 'directory',
|
||||||
|
'certs': cert_views,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@resource('/v1/superuser/keys')
|
||||||
|
class SuperUserServiceKeyManagement(ApiResource):
|
||||||
|
""" Resource for managing service keys."""
|
||||||
|
schemas = {
|
||||||
|
'CreateServiceKey': {
|
||||||
|
'id': 'CreateServiceKey',
|
||||||
|
'type': 'object',
|
||||||
|
'description': 'Description of creation of a service key',
|
||||||
|
'required': ['service', 'expiration'],
|
||||||
|
'properties': {
|
||||||
|
'service': {
|
||||||
|
'type': 'string',
|
||||||
|
'description': 'The service authenticating with this key',
|
||||||
|
},
|
||||||
|
'name': {
|
||||||
|
'type': 'string',
|
||||||
|
'description': 'The friendly name of a service key',
|
||||||
|
},
|
||||||
|
'metadata': {
|
||||||
|
'type': 'object',
|
||||||
|
'description': 'The key/value pairs of this key\'s metadata',
|
||||||
|
},
|
||||||
|
'notes': {
|
||||||
|
'type': 'string',
|
||||||
|
'description': 'If specified, the extra notes for the key',
|
||||||
|
},
|
||||||
|
'expiration': {
|
||||||
|
'description': 'The expiration date as a unix timestamp',
|
||||||
|
'anyOf': [{'type': 'number'}, {'type': 'null'}],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
@nickname('listServiceKeys')
|
||||||
|
def get(self):
|
||||||
|
keys = pre_oci_model.list_all_service_keys()
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'keys': [key.to_dict() for key in keys],
|
||||||
|
})
|
||||||
|
|
||||||
|
|
170
config_app/config_endpoints/api/superuser_models_interface.py
Normal file
170
config_app/config_endpoints/api/superuser_models_interface.py
Normal file
|
@ -0,0 +1,170 @@
|
||||||
|
from abc import ABCMeta, abstractmethod
|
||||||
|
from collections import namedtuple
|
||||||
|
from six import add_metaclass
|
||||||
|
|
||||||
|
from config_app.config_endpoints.api import format_date
|
||||||
|
|
||||||
|
|
||||||
|
def user_view(user):
|
||||||
|
return {
|
||||||
|
'name': user.username,
|
||||||
|
'kind': 'user',
|
||||||
|
'is_robot': user.robot,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class RepositoryBuild(namedtuple('RepositoryBuild',
|
||||||
|
['uuid', 'logs_archived', 'repository_namespace_user_username', 'repository_name',
|
||||||
|
'can_write', 'can_read', 'pull_robot', 'resource_key', 'trigger', 'display_name',
|
||||||
|
'started', 'job_config', 'phase', 'status', 'error', 'archive_url'])):
|
||||||
|
"""
|
||||||
|
RepositoryBuild represents a build associated with a repostiory
|
||||||
|
:type uuid: string
|
||||||
|
:type logs_archived: boolean
|
||||||
|
:type repository_namespace_user_username: string
|
||||||
|
:type repository_name: string
|
||||||
|
:type can_write: boolean
|
||||||
|
:type can_write: boolean
|
||||||
|
:type pull_robot: User
|
||||||
|
:type resource_key: string
|
||||||
|
:type trigger: Trigger
|
||||||
|
:type display_name: string
|
||||||
|
:type started: boolean
|
||||||
|
:type job_config: {Any -> Any}
|
||||||
|
:type phase: string
|
||||||
|
:type status: string
|
||||||
|
:type error: string
|
||||||
|
:type archive_url: string
|
||||||
|
"""
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
|
||||||
|
resp = {
|
||||||
|
'id': self.uuid,
|
||||||
|
'phase': self.phase,
|
||||||
|
'started': format_date(self.started),
|
||||||
|
'display_name': self.display_name,
|
||||||
|
'status': self.status or {},
|
||||||
|
'subdirectory': self.job_config.get('build_subdir', ''),
|
||||||
|
'dockerfile_path': self.job_config.get('build_subdir', ''),
|
||||||
|
'context': self.job_config.get('context', ''),
|
||||||
|
'tags': self.job_config.get('docker_tags', []),
|
||||||
|
'manual_user': self.job_config.get('manual_user', None),
|
||||||
|
'is_writer': self.can_write,
|
||||||
|
'trigger': self.trigger.to_dict(),
|
||||||
|
'trigger_metadata': self.job_config.get('trigger_metadata', None) if self.can_read else None,
|
||||||
|
'resource_key': self.resource_key,
|
||||||
|
'pull_robot': user_view(self.pull_robot) if self.pull_robot else None,
|
||||||
|
'repository': {
|
||||||
|
'namespace': self.repository_namespace_user_username,
|
||||||
|
'name': self.repository_name
|
||||||
|
},
|
||||||
|
'error': self.error,
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.can_write:
|
||||||
|
if self.resource_key is not None:
|
||||||
|
resp['archive_url'] = self.archive_url
|
||||||
|
elif self.job_config.get('archive_url', None):
|
||||||
|
resp['archive_url'] = self.job_config['archive_url']
|
||||||
|
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
class Approval(namedtuple('Approval', ['approver', 'approval_type', 'approved_date', 'notes'])):
|
||||||
|
"""
|
||||||
|
Approval represents whether a key has been approved or not
|
||||||
|
:type approver: User
|
||||||
|
:type approval_type: string
|
||||||
|
:type approved_date: Date
|
||||||
|
:type notes: string
|
||||||
|
"""
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
return {
|
||||||
|
'approver': self.approver.to_dict() if self.approver else None,
|
||||||
|
'approval_type': self.approval_type,
|
||||||
|
'approved_date': self.approved_date,
|
||||||
|
'notes': self.notes,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceKey(namedtuple('ServiceKey', ['name', 'kid', 'service', 'jwk', 'metadata', 'created_date',
|
||||||
|
'expiration_date', 'rotation_duration', 'approval'])):
|
||||||
|
"""
|
||||||
|
ServiceKey is an apostille signing key
|
||||||
|
:type name: string
|
||||||
|
:type kid: int
|
||||||
|
:type service: string
|
||||||
|
:type jwk: string
|
||||||
|
:type metadata: string
|
||||||
|
:type created_date: Date
|
||||||
|
:type expiration_date: Date
|
||||||
|
:type rotation_duration: Date
|
||||||
|
:type approval: Approval
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
return {
|
||||||
|
'name': self.name,
|
||||||
|
'kid': self.kid,
|
||||||
|
'service': self.service,
|
||||||
|
'jwk': self.jwk,
|
||||||
|
'metadata': self.metadata,
|
||||||
|
'created_date': self.created_date,
|
||||||
|
'expiration_date': self.expiration_date,
|
||||||
|
'rotation_duration': self.rotation_duration,
|
||||||
|
'approval': self.approval.to_dict() if self.approval is not None else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class User(namedtuple('User', ['username', 'email', 'verified', 'enabled', 'robot'])):
|
||||||
|
"""
|
||||||
|
User represents a single user.
|
||||||
|
:type username: string
|
||||||
|
:type email: string
|
||||||
|
:type verified: boolean
|
||||||
|
:type enabled: boolean
|
||||||
|
:type robot: User
|
||||||
|
"""
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
user_data = {
|
||||||
|
'kind': 'user',
|
||||||
|
'name': self.username,
|
||||||
|
'username': self.username,
|
||||||
|
'email': self.email,
|
||||||
|
'verified': self.verified,
|
||||||
|
'enabled': self.enabled,
|
||||||
|
}
|
||||||
|
|
||||||
|
return user_data
|
||||||
|
|
||||||
|
|
||||||
|
class Organization(namedtuple('Organization', ['username', 'email'])):
|
||||||
|
"""
|
||||||
|
Organization represents a single org.
|
||||||
|
:type username: string
|
||||||
|
:type email: string
|
||||||
|
"""
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
return {
|
||||||
|
'name': self.username,
|
||||||
|
'email': self.email,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@add_metaclass(ABCMeta)
|
||||||
|
class SuperuserDataInterface(object):
|
||||||
|
"""
|
||||||
|
Interface that represents all data store interactions required by a superuser api.
|
||||||
|
"""
|
||||||
|
@abstractmethod
|
||||||
|
def list_all_service_keys(self):
|
||||||
|
"""
|
||||||
|
Returns a list of service keys
|
||||||
|
"""
|
31
config_app/config_endpoints/api/superuser_models_pre_oci.py
Normal file
31
config_app/config_endpoints/api/superuser_models_pre_oci.py
Normal file
|
@ -0,0 +1,31 @@
|
||||||
|
from data import model
|
||||||
|
|
||||||
|
from config_app.config_endpoints.api.superuser_models_interface import SuperuserDataInterface, User, ServiceKey, Approval
|
||||||
|
|
||||||
|
def _create_user(user):
|
||||||
|
if user is None:
|
||||||
|
return None
|
||||||
|
return User(user.username, user.email, user.verified, user.enabled, user.robot)
|
||||||
|
|
||||||
|
|
||||||
|
def _create_key(key):
|
||||||
|
approval = None
|
||||||
|
if key.approval is not None:
|
||||||
|
approval = Approval(_create_user(key.approval.approver), key.approval.approval_type, key.approval.approved_date,
|
||||||
|
key.approval.notes)
|
||||||
|
|
||||||
|
return ServiceKey(key.name, key.kid, key.service, key.jwk, key.metadata, key.created_date, key.expiration_date,
|
||||||
|
key.rotation_duration, approval)
|
||||||
|
|
||||||
|
|
||||||
|
class PreOCIModel(SuperuserDataInterface):
|
||||||
|
"""
|
||||||
|
PreOCIModel implements the data model for the SuperUser using a database schema
|
||||||
|
before it was changed to support the OCI specification.
|
||||||
|
"""
|
||||||
|
def list_all_service_keys(self):
|
||||||
|
keys = model.service_keys.list_all_keys()
|
||||||
|
return [_create_key(key) for key in keys]
|
||||||
|
|
||||||
|
|
||||||
|
pre_oci_model = PreOCIModel()
|
19
config_app/config_endpoints/api/user.py
Normal file
19
config_app/config_endpoints/api/user.py
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
from auth.auth_context import get_authenticated_user
|
||||||
|
from config_app.config_endpoints.api import resource, ApiResource, nickname
|
||||||
|
from config_app.config_endpoints.api.superuser_models_interface import user_view
|
||||||
|
|
||||||
|
|
||||||
|
@resource('/v1/user/')
|
||||||
|
class User(ApiResource):
|
||||||
|
""" Operations related to users. """
|
||||||
|
|
||||||
|
@nickname('getLoggedInUser')
|
||||||
|
def get(self):
|
||||||
|
""" Get user information for the authenticated user. """
|
||||||
|
user = get_authenticated_user()
|
||||||
|
# TODO(config): figure out if we need user validation
|
||||||
|
# if user is None or user.organization or not UserReadPermission(user.username).can():
|
||||||
|
# raise InvalidToken("Requires authentication", payload={'session_required': False})
|
||||||
|
|
||||||
|
return user_view(user)
|
||||||
|
|
58
config_app/config_endpoints/common.py
Normal file
58
config_app/config_endpoints/common.py
Normal file
|
@ -0,0 +1,58 @@
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
|
||||||
|
from flask import make_response, render_template
|
||||||
|
from flask_restful import reqparse
|
||||||
|
|
||||||
|
from config_app._init_config import ROOT_DIR
|
||||||
|
|
||||||
|
|
||||||
|
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(ROOT_DIR) + 1 + len('config_app/static/'):]
|
||||||
|
|
||||||
|
filepath = os.path.join(os.path.join(ROOT_DIR, 'config_app/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)
|
||||||
|
|
||||||
|
contents = render_template(name,
|
||||||
|
route_data=route_data,
|
||||||
|
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__)
|
||||||
|
|
||||||
|
|
67
config_app/config_endpoints/exception.py
Normal file
67
config_app/config_endpoints/exception.py
Normal file
|
@ -0,0 +1,67 @@
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
from flask import url_for
|
||||||
|
from werkzeug.exceptions import HTTPException
|
||||||
|
|
||||||
|
|
||||||
|
class ApiErrorType(Enum):
|
||||||
|
invalid_request = 'invalid_request'
|
||||||
|
|
||||||
|
|
||||||
|
class ApiException(HTTPException):
|
||||||
|
"""
|
||||||
|
Represents an error in the application/problem+json format.
|
||||||
|
|
||||||
|
See: https://tools.ietf.org/html/rfc7807
|
||||||
|
|
||||||
|
- "type" (string) - A URI reference that identifies the
|
||||||
|
problem type.
|
||||||
|
|
||||||
|
- "title" (string) - A short, human-readable summary of the problem
|
||||||
|
type. It SHOULD NOT change from occurrence to occurrence of the
|
||||||
|
problem, except for purposes of localization
|
||||||
|
|
||||||
|
- "status" (number) - The HTTP status code
|
||||||
|
|
||||||
|
- "detail" (string) - A human-readable explanation specific to this
|
||||||
|
occurrence of the problem.
|
||||||
|
|
||||||
|
- "instance" (string) - A URI reference that identifies the specific
|
||||||
|
occurrence of the problem. It may or may not yield further
|
||||||
|
information if dereferenced.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, error_type, status_code, error_description, payload=None):
|
||||||
|
Exception.__init__(self)
|
||||||
|
self.error_description = error_description
|
||||||
|
self.code = status_code
|
||||||
|
self.payload = payload
|
||||||
|
self.error_type = error_type
|
||||||
|
self.data = self.to_dict()
|
||||||
|
|
||||||
|
super(ApiException, self).__init__(error_description, None)
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
rv = dict(self.payload or ())
|
||||||
|
|
||||||
|
if self.error_description is not None:
|
||||||
|
rv['detail'] = self.error_description
|
||||||
|
rv['error_message'] = self.error_description # TODO: deprecate
|
||||||
|
|
||||||
|
rv['error_type'] = self.error_type.value # TODO: deprecate
|
||||||
|
rv['title'] = self.error_type.value
|
||||||
|
rv['type'] = url_for('api.error', error_type=self.error_type.value, _external=True)
|
||||||
|
rv['status'] = self.code
|
||||||
|
|
||||||
|
return rv
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidRequest(ApiException):
|
||||||
|
def __init__(self, error_description, payload=None):
|
||||||
|
ApiException.__init__(self, ApiErrorType.invalid_request, 400, error_description, payload)
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidResponse(ApiException):
|
||||||
|
def __init__(self, error_description, payload=None):
|
||||||
|
ApiException.__init__(self, ApiErrorType.invalid_response, 400, error_description, payload)
|
26
config_app/config_endpoints/setup_web.py
Normal file
26
config_app/config_endpoints/setup_web.py
Normal file
|
@ -0,0 +1,26 @@
|
||||||
|
from flask import Blueprint
|
||||||
|
from cachetools import lru_cache
|
||||||
|
|
||||||
|
from config_app.config_endpoints.common import render_page_template
|
||||||
|
from config_app.config_endpoints.api.discovery import generate_route_data
|
||||||
|
from config_app.config_endpoints.api import no_cache
|
||||||
|
|
||||||
|
|
||||||
|
setup_web = Blueprint('setup_web', __name__, template_folder='templates')
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=1)
|
||||||
|
def _get_route_data():
|
||||||
|
return generate_route_data()
|
||||||
|
|
||||||
|
|
||||||
|
def render_page_template_with_routedata(name, *args, **kwargs):
|
||||||
|
return render_page_template(name, _get_route_data(), *args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
@no_cache
|
||||||
|
@setup_web.route('/', methods=['GET'], defaults={'path': ''})
|
||||||
|
def index(path, **kwargs):
|
||||||
|
return render_page_template_with_routedata('index.html', js_bundle_name='configapp', **kwargs)
|
||||||
|
|
||||||
|
|
0
config_app/config_util/__init__.py
Normal file
0
config_app/config_util/__init__.py
Normal file
12
config_app/config_util/config/__init__.py
Normal file
12
config_app/config_util/config/__init__.py
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
from config_app.config_util.config.fileprovider import FileConfigProvider
|
||||||
|
from config_app.config_util.config.testprovider import TestConfigProvider
|
||||||
|
|
||||||
|
|
||||||
|
def get_config_provider(config_volume, yaml_filename, py_filename, testing=False):
|
||||||
|
""" Loads and returns the config provider for the current environment. """
|
||||||
|
if testing:
|
||||||
|
return TestConfigProvider()
|
||||||
|
|
||||||
|
return FileConfigProvider(config_volume, yaml_filename, py_filename)
|
||||||
|
|
||||||
|
|
71
config_app/config_util/config/basefileprovider.py
Normal file
71
config_app/config_util/config/basefileprovider.py
Normal file
|
@ -0,0 +1,71 @@
|
||||||
|
import os
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from config_app.config_util.config.baseprovider import (BaseProvider, import_yaml, export_yaml,
|
||||||
|
CannotWriteConfigException)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class BaseFileProvider(BaseProvider):
|
||||||
|
""" Base implementation of the config provider that reads the data from the file system. """
|
||||||
|
def __init__(self, config_volume, yaml_filename, py_filename):
|
||||||
|
self.config_volume = config_volume
|
||||||
|
self.yaml_filename = yaml_filename
|
||||||
|
self.py_filename = py_filename
|
||||||
|
|
||||||
|
self.yaml_path = os.path.join(config_volume, yaml_filename)
|
||||||
|
self.py_path = os.path.join(config_volume, py_filename)
|
||||||
|
|
||||||
|
def update_app_config(self, app_config):
|
||||||
|
if os.path.exists(self.py_path):
|
||||||
|
logger.debug('Applying config file: %s', self.py_path)
|
||||||
|
app_config.from_pyfile(self.py_path)
|
||||||
|
|
||||||
|
if os.path.exists(self.yaml_path):
|
||||||
|
logger.debug('Applying config file: %s', self.yaml_path)
|
||||||
|
import_yaml(app_config, self.yaml_path)
|
||||||
|
|
||||||
|
def get_config(self):
|
||||||
|
if not self.config_exists():
|
||||||
|
return None
|
||||||
|
|
||||||
|
config_obj = {}
|
||||||
|
import_yaml(config_obj, self.yaml_path)
|
||||||
|
return config_obj
|
||||||
|
|
||||||
|
def config_exists(self):
|
||||||
|
return self.volume_file_exists(self.yaml_filename)
|
||||||
|
|
||||||
|
def volume_exists(self):
|
||||||
|
return os.path.exists(self.config_volume)
|
||||||
|
|
||||||
|
def volume_file_exists(self, filename):
|
||||||
|
return os.path.exists(os.path.join(self.config_volume, filename))
|
||||||
|
|
||||||
|
def get_volume_file(self, filename, mode='r'):
|
||||||
|
return open(os.path.join(self.config_volume, filename), mode=mode)
|
||||||
|
|
||||||
|
def get_volume_path(self, directory, filename):
|
||||||
|
return os.path.join(directory, filename)
|
||||||
|
|
||||||
|
def list_volume_directory(self, path):
|
||||||
|
dirpath = os.path.join(self.config_volume, path)
|
||||||
|
if not os.path.exists(dirpath):
|
||||||
|
return None
|
||||||
|
|
||||||
|
if not os.path.isdir(dirpath):
|
||||||
|
return None
|
||||||
|
|
||||||
|
return os.listdir(dirpath)
|
||||||
|
|
||||||
|
def requires_restart(self, app_config):
|
||||||
|
file_config = self.get_config()
|
||||||
|
if not file_config:
|
||||||
|
return False
|
||||||
|
|
||||||
|
for key in file_config:
|
||||||
|
if app_config.get(key) != file_config[key]:
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
128
config_app/config_util/config/baseprovider.py
Normal file
128
config_app/config_util/config/baseprovider.py
Normal 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 """
|
60
config_app/config_util/config/fileprovider.py
Normal file
60
config_app/config_util/config/fileprovider.py
Normal file
|
@ -0,0 +1,60 @@
|
||||||
|
import os
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from config_app.config_util.config.baseprovider import export_yaml, CannotWriteConfigException
|
||||||
|
from config_app.config_util.config.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
|
81
config_app/config_util/config/testprovider.py
Normal file
81
config_app/config_util/config/testprovider.py
Normal file
|
@ -0,0 +1,81 @@
|
||||||
|
import json
|
||||||
|
import io
|
||||||
|
import os
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
from config_app.config_util.config.baseprovider import BaseProvider
|
||||||
|
|
||||||
|
REAL_FILES = ['test/data/signing-private.gpg', 'test/data/signing-public.gpg', 'test/data/test.pem']
|
||||||
|
|
||||||
|
|
||||||
|
class TestConfigProvider(BaseProvider):
|
||||||
|
""" Implementation of the config provider for testing. Everything is kept in-memory instead on
|
||||||
|
the real file system. """
|
||||||
|
def __init__(self):
|
||||||
|
self.clear()
|
||||||
|
|
||||||
|
def clear(self):
|
||||||
|
self.files = {}
|
||||||
|
self._config = {}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def provider_id(self):
|
||||||
|
return 'test'
|
||||||
|
|
||||||
|
def update_app_config(self, app_config):
|
||||||
|
self._config = app_config
|
||||||
|
|
||||||
|
def get_config(self):
|
||||||
|
if not 'config.yaml' in self.files:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return json.loads(self.files.get('config.yaml', '{}'))
|
||||||
|
|
||||||
|
def save_config(self, config_obj):
|
||||||
|
self.files['config.yaml'] = json.dumps(config_obj)
|
||||||
|
|
||||||
|
def config_exists(self):
|
||||||
|
return 'config.yaml' in self.files
|
||||||
|
|
||||||
|
def volume_exists(self):
|
||||||
|
return True
|
||||||
|
|
||||||
|
def volume_file_exists(self, filename):
|
||||||
|
if filename in REAL_FILES:
|
||||||
|
return True
|
||||||
|
|
||||||
|
return filename in self.files
|
||||||
|
|
||||||
|
def save_volume_file(self, filename, flask_file):
|
||||||
|
self.files[filename] = flask_file.read()
|
||||||
|
|
||||||
|
def write_volume_file(self, filename, contents):
|
||||||
|
self.files[filename] = contents
|
||||||
|
|
||||||
|
def get_volume_file(self, filename, mode='r'):
|
||||||
|
if filename in REAL_FILES:
|
||||||
|
return open(filename, mode=mode)
|
||||||
|
|
||||||
|
return io.BytesIO(self.files[filename])
|
||||||
|
|
||||||
|
def remove_volume_file(self, filename):
|
||||||
|
self.files.pop(filename, None)
|
||||||
|
|
||||||
|
def list_volume_directory(self, path):
|
||||||
|
paths = []
|
||||||
|
for filename in self.files:
|
||||||
|
if filename.startswith(path):
|
||||||
|
paths.append(filename[len(path)+1:])
|
||||||
|
|
||||||
|
return paths
|
||||||
|
|
||||||
|
def requires_restart(self, app_config):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def reset_for_test(self):
|
||||||
|
self._config['SUPER_USERS'] = ['devtable']
|
||||||
|
self.files = {}
|
||||||
|
|
||||||
|
def get_volume_path(self, directory, filename):
|
||||||
|
return os.path.join(directory, filename)
|
||||||
|
|
47
config_app/config_util/log.py
Normal file
47
config_app/config_util/log.py
Normal file
|
@ -0,0 +1,47 @@
|
||||||
|
import os
|
||||||
|
from config_app._init_config import CONF_DIR
|
||||||
|
|
||||||
|
|
||||||
|
def logfile_path(jsonfmt=False, debug=False):
|
||||||
|
"""
|
||||||
|
Returns the a logfileconf path following this rules:
|
||||||
|
- conf/logging_debug_json.conf # jsonfmt=true, debug=true
|
||||||
|
- conf/logging_json.conf # jsonfmt=true, debug=false
|
||||||
|
- conf/logging_debug.conf # jsonfmt=false, debug=true
|
||||||
|
- conf/logging.conf # jsonfmt=false, debug=false
|
||||||
|
Can be parametrized via envvars: JSONLOG=true, DEBUGLOG=true
|
||||||
|
"""
|
||||||
|
_json = ""
|
||||||
|
_debug = ""
|
||||||
|
|
||||||
|
if jsonfmt or os.getenv('JSONLOG', 'false').lower() == 'true':
|
||||||
|
_json = "_json"
|
||||||
|
|
||||||
|
if debug or os.getenv('DEBUGLOG', 'false').lower() == 'true':
|
||||||
|
_debug = "_debug"
|
||||||
|
|
||||||
|
return os.path.join(CONF_DIR, "logging%s%s.conf" % (_debug, _json))
|
||||||
|
|
||||||
|
|
||||||
|
def filter_logs(values, filtered_fields):
|
||||||
|
"""
|
||||||
|
Takes a dict and a list of keys to filter.
|
||||||
|
eg:
|
||||||
|
with filtered_fields:
|
||||||
|
[{'key': ['k1', k2'], 'fn': lambda x: 'filtered'}]
|
||||||
|
and values:
|
||||||
|
{'k1': {'k2': 'some-secret'}, 'k3': 'some-value'}
|
||||||
|
the returned dict is:
|
||||||
|
{'k1': {k2: 'filtered'}, 'k3': 'some-value'}
|
||||||
|
"""
|
||||||
|
for field in filtered_fields:
|
||||||
|
cdict = values
|
||||||
|
|
||||||
|
for key in field['key'][:-1]:
|
||||||
|
if key in cdict:
|
||||||
|
cdict = cdict[key]
|
||||||
|
|
||||||
|
last_key = field['key'][-1]
|
||||||
|
|
||||||
|
if last_key in cdict and cdict[last_key]:
|
||||||
|
cdict[last_key] = field['fn'](cdict[last_key])
|
81
config_app/config_util/ssl.py
Normal file
81
config_app/config_util/ssl.py
Normal file
|
@ -0,0 +1,81 @@
|
||||||
|
from fnmatch import fnmatch
|
||||||
|
|
||||||
|
import OpenSSL
|
||||||
|
|
||||||
|
class CertInvalidException(Exception):
|
||||||
|
""" Exception raised when a certificate could not be parsed/loaded. """
|
||||||
|
pass
|
||||||
|
|
||||||
|
class KeyInvalidException(Exception):
|
||||||
|
""" Exception raised when a key could not be parsed/loaded or successfully applied to a cert. """
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def load_certificate(cert_contents):
|
||||||
|
""" Loads the certificate from the given contents and returns it or raises a CertInvalidException
|
||||||
|
on failure.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
cert = OpenSSL.crypto.load_certificate(OpenSSL.crypto.FILETYPE_PEM, cert_contents)
|
||||||
|
return SSLCertificate(cert)
|
||||||
|
except OpenSSL.crypto.Error as ex:
|
||||||
|
raise CertInvalidException(ex.message[0][2])
|
||||||
|
|
||||||
|
|
||||||
|
_SUBJECT_ALT_NAME = 'subjectAltName'
|
||||||
|
|
||||||
|
class SSLCertificate(object):
|
||||||
|
""" Helper class for easier working with SSL certificates. """
|
||||||
|
def __init__(self, openssl_cert):
|
||||||
|
self.openssl_cert = openssl_cert
|
||||||
|
|
||||||
|
def validate_private_key(self, private_key_path):
|
||||||
|
""" Validates that the private key found at the given file path applies to this certificate.
|
||||||
|
Raises a KeyInvalidException on failure.
|
||||||
|
"""
|
||||||
|
context = OpenSSL.SSL.Context(OpenSSL.SSL.TLSv1_METHOD)
|
||||||
|
context.use_certificate(self.openssl_cert)
|
||||||
|
|
||||||
|
try:
|
||||||
|
context.use_privatekey_file(private_key_path)
|
||||||
|
context.check_privatekey()
|
||||||
|
except OpenSSL.SSL.Error as ex:
|
||||||
|
raise KeyInvalidException(ex.message[0][2])
|
||||||
|
|
||||||
|
def matches_name(self, check_name):
|
||||||
|
""" Returns true if this SSL certificate matches the given DNS hostname. """
|
||||||
|
for dns_name in self.names:
|
||||||
|
if fnmatch(check_name, dns_name):
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
@property
|
||||||
|
def expired(self):
|
||||||
|
""" Returns whether the SSL certificate has expired. """
|
||||||
|
return self.openssl_cert.has_expired()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def common_name(self):
|
||||||
|
""" Returns the defined common name for the certificate, if any. """
|
||||||
|
return self.openssl_cert.get_subject().commonName
|
||||||
|
|
||||||
|
@property
|
||||||
|
def names(self):
|
||||||
|
""" Returns all the DNS named to which the certificate applies. May be empty. """
|
||||||
|
dns_names = set()
|
||||||
|
common_name = self.common_name
|
||||||
|
if common_name is not None:
|
||||||
|
dns_names.add(common_name)
|
||||||
|
|
||||||
|
# Find the DNS extension, if any.
|
||||||
|
for i in range(0, self.openssl_cert.get_extension_count()):
|
||||||
|
ext = self.openssl_cert.get_extension(i)
|
||||||
|
if ext.get_short_name() == _SUBJECT_ALT_NAME:
|
||||||
|
value = str(ext)
|
||||||
|
for san_name in value.split(','):
|
||||||
|
san_name_trimmed = san_name.strip()
|
||||||
|
if san_name_trimmed.startswith('DNS:'):
|
||||||
|
dns_names.add(san_name_trimmed[4:])
|
||||||
|
|
||||||
|
return dns_names
|
8
config_app/config_web.py
Normal file
8
config_app/config_web.py
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
from config_app.c_app import app as application
|
||||||
|
from config_app.config_endpoints.api import api_bp
|
||||||
|
from config_app.config_endpoints.setup_web import setup_web
|
||||||
|
|
||||||
|
|
||||||
|
application.register_blueprint(setup_web)
|
||||||
|
application.register_blueprint(api_bp, url_prefix='/api')
|
||||||
|
|
11
config_app/init/service/gunicorn_web/run
Executable file
11
config_app/init/service/gunicorn_web/run
Executable file
|
@ -0,0 +1,11 @@
|
||||||
|
#! /bin/bash
|
||||||
|
|
||||||
|
echo 'Starting gunicon'
|
||||||
|
|
||||||
|
QUAYPATH=${QUAYPATH:-"."}
|
||||||
|
QUAYCONF=${QUAYCONF:-"$QUAYPATH/conf"}
|
||||||
|
|
||||||
|
cd ${QUAYDIR:-"/"}
|
||||||
|
PYTHONPATH=$QUAYPATH venv/bin/gunicorn -c $QUAYDIR/config_app/conf/gunicorn_web.py config_application:application
|
||||||
|
|
||||||
|
echo 'Gunicorn exited'
|
|
@ -0,0 +1,4 @@
|
||||||
|
<div class="cor-progress-bar-element progress">
|
||||||
|
<div class="progress-bar" ng-style="{'width': (progress * 100) + '%'}"
|
||||||
|
aria-valuenow="{{ (progress * 100) }}" aria-valuemin="0" aria-valuemax="100"></div>
|
||||||
|
</div>
|
|
@ -0,0 +1,74 @@
|
||||||
|
|
||||||
|
|
||||||
|
const corStepBarUrl = require('./cor-step-bar.html');
|
||||||
|
const corStepUrl = require('./cor-step.html');
|
||||||
|
const corProgressBarUrl = require('./cor-progress-bar.html');
|
||||||
|
|
||||||
|
angular.module('quay-config')
|
||||||
|
.directive('corStepBar', () => {
|
||||||
|
const directiveDefinitionObject = {
|
||||||
|
priority: 4,
|
||||||
|
templateUrl: corStepBarUrl,
|
||||||
|
replace: true,
|
||||||
|
transclude: true,
|
||||||
|
restrict: 'C',
|
||||||
|
scope: {
|
||||||
|
'progress': '=progress'
|
||||||
|
},
|
||||||
|
controller: function($rootScope, $scope, $element) {
|
||||||
|
$scope.$watch('progress', function(progress) {
|
||||||
|
if (!progress) { return; }
|
||||||
|
|
||||||
|
var index = 0;
|
||||||
|
for (var i = 0; i < progress.length; ++i) {
|
||||||
|
if (progress[i]) {
|
||||||
|
index = i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$element.find('.transclude').children('.co-step-element').each(function(i, elem) {
|
||||||
|
$(elem).removeClass('active');
|
||||||
|
if (i <= index) {
|
||||||
|
$(elem).addClass('active');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return directiveDefinitionObject;
|
||||||
|
})
|
||||||
|
|
||||||
|
.directive('corStep', function() {
|
||||||
|
var directiveDefinitionObject = {
|
||||||
|
priority: 4,
|
||||||
|
templateUrl: corStepUrl,
|
||||||
|
replace: true,
|
||||||
|
transclude: false,
|
||||||
|
requires: '^corStepBar',
|
||||||
|
restrict: 'C',
|
||||||
|
scope: {
|
||||||
|
'icon': '@icon',
|
||||||
|
'title': '@title',
|
||||||
|
'text': '@text'
|
||||||
|
},
|
||||||
|
controller: function($rootScope, $scope, $element) {
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return directiveDefinitionObject;
|
||||||
|
})
|
||||||
|
|
||||||
|
.directive('corProgressBar', function() {
|
||||||
|
var directiveDefinitionObject = {
|
||||||
|
priority: 4,
|
||||||
|
templateUrl: corProgressBarUrl,
|
||||||
|
replace: true,
|
||||||
|
transclude: true,
|
||||||
|
restrict: 'C',
|
||||||
|
scope: {
|
||||||
|
'progress': '=progress'
|
||||||
|
},
|
||||||
|
controller: function($rootScope, $scope, $element) {
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return directiveDefinitionObject;
|
||||||
|
});
|
3
config_app/js/components/cor-progress/cor-step-bar.html
Normal file
3
config_app/js/components/cor-progress/cor-step-bar.html
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
<div class="co-step-bar">
|
||||||
|
<span class="transclude" ng-transclude/>
|
||||||
|
</div>
|
6
config_app/js/components/cor-progress/cor-step.html
Normal file
6
config_app/js/components/cor-progress/cor-step.html
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
<span ng-class="text ? 'co-step-element text' : 'co-step-element icon'">
|
||||||
|
<span data-title="{{ title }}" bs-tooltip>
|
||||||
|
<span class="text" ng-if="text">{{ text }}</span>
|
||||||
|
<i class="fa fa-lg" ng-if="icon" ng-class="'fa-' + icon"></i>
|
||||||
|
</span>
|
||||||
|
</span>
|
46
config_app/js/components/file-upload-box.html
Normal file
46
config_app/js/components/file-upload-box.html
Normal file
|
@ -0,0 +1,46 @@
|
||||||
|
<div class="file-upload-box-element">
|
||||||
|
<div class="file-input-container">
|
||||||
|
<div ng-show="state != 'uploading'">
|
||||||
|
<form id="file-drop-form-{{ boxId }}">
|
||||||
|
<input id="file-drop-{{ boxId }}" name="file-drop-{{ boxId }}" class="file-drop"
|
||||||
|
type="file" files-changed="handleFilesChanged(files)"
|
||||||
|
accept="{{ getAccepts(extensions) }}">
|
||||||
|
<label for="file-drop-{{ boxId }}" ng-class="state">
|
||||||
|
<span class="chosen-file">
|
||||||
|
<span ng-if="selectedFiles.length">
|
||||||
|
{{ selectedFiles[0].name }}
|
||||||
|
<span ng-if="selectedFiles.length > 1">
|
||||||
|
and {{ selectedFiles.length - 1 }} others...
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</span><span class="choose-button">
|
||||||
|
<span>Select file</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="cor-loader-line" ng-if="state == 'checking'"></div>
|
||||||
|
|
||||||
|
<div class="status-message" ng-if="state == 'uploading'">
|
||||||
|
<div class="progress progress-striped active">
|
||||||
|
<div class="progress-bar" role="progressbar"
|
||||||
|
aria-valuenow="{{ uploadProgress }}" aria-valuemin="0" aria-valuemax="100"
|
||||||
|
style="{{ 'width: ' + uploadProgress + '%' }}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
Uploading file {{ currentlyUploadingFile.name }}...
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="select-message" ng-if="state == 'clear'">{{ selectMessage }}</div>
|
||||||
|
<div class="status-message error-message" ng-if="state == 'error'">
|
||||||
|
<i class="fa fa-times-circle"></i>
|
||||||
|
{{ message }}
|
||||||
|
</div>
|
||||||
|
<div class="status-message okay-message" ng-if="state == 'okay'">
|
||||||
|
<i class="fa fa-check-circle"></i>
|
||||||
|
{{ message }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
173
config_app/js/components/file-upload-box.js
Normal file
173
config_app/js/components/file-upload-box.js
Normal file
|
@ -0,0 +1,173 @@
|
||||||
|
const templateUrl = require('./file-upload-box.html');
|
||||||
|
/**
|
||||||
|
* An element which adds a stylize box for uploading a file.
|
||||||
|
*/
|
||||||
|
angular.module('quay-config').directive('fileUploadBox', function () {
|
||||||
|
var directiveDefinitionObject = {
|
||||||
|
priority: 0,
|
||||||
|
templateUrl,
|
||||||
|
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;
|
||||||
|
});
|
43
config_app/js/config-app.module.ts
Normal file
43
config_app/js/config-app.module.ts
Normal file
|
@ -0,0 +1,43 @@
|
||||||
|
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/');
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@NgModule({
|
||||||
|
imports: [ DependencyConfig ],
|
||||||
|
declarations: [],
|
||||||
|
providers: []
|
||||||
|
})
|
||||||
|
export class ConfigAppModule {}
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
13
config_app/js/config-field-templates/config-file-field.html
Normal file
13
config_app/js/config-field-templates/config-file-field.html
Normal 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>
|
17
config_app/js/config-field-templates/config-list-field.html
Normal file
17
config_app/js/config-field-templates/config-list-field.html
Normal 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>
|
20
config_app/js/config-field-templates/config-map-field.html
Normal file
20
config_app/js/config-field-templates/config-map-field.html
Normal 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>
|
|
@ -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>
|
|
@ -0,0 +1 @@
|
||||||
|
<div class="config-parsed-field-element"></div>
|
|
@ -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>
|
1656
config_app/js/config-field-templates/config-setup-tool.html
Normal file
1656
config_app/js/config-field-templates/config-setup-tool.html
Normal file
File diff suppressed because it is too large
Load diff
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
1657
config_app/js/core-config-setup/config-setup-tool.html
Normal file
1657
config_app/js/core-config-setup/config-setup-tool.html
Normal file
File diff suppressed because it is too large
Load diff
1452
config_app/js/core-config-setup/core-config-setup.js
Normal file
1452
config_app/js/core-config-setup/core-config-setup.js
Normal file
File diff suppressed because it is too large
Load diff
35
config_app/js/main.ts
Normal file
35
config_app/js/main.ts
Normal file
|
@ -0,0 +1,35 @@
|
||||||
|
// imports shims, etc
|
||||||
|
import 'core-js';
|
||||||
|
|
||||||
|
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(() => {
|
||||||
|
});
|
||||||
|
|
||||||
|
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$/));
|
||||||
|
requireAll(require.context('./components', true, /\.js$/));
|
||||||
|
|
||||||
|
|
||||||
|
// Load all the main quay css
|
||||||
|
requireAll(require.context('../../static/css', true, /\.css$/));
|
||||||
|
requireAll(require.context('../../static/lib', true, /\.css$/));
|
327
config_app/js/services/api-service.js
Normal file
327
config_app/js/services/api-service.js
Normal file
|
@ -0,0 +1,327 @@
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
return apiService;
|
||||||
|
}]);
|
43
config_app/js/services/container-service.js
Normal file
43
config_app/js/services/container-service.js
Normal file
|
@ -0,0 +1,43 @@
|
||||||
|
/**
|
||||||
|
* 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.errorDisplay('Removed Endpoint. This error should never be seen.')
|
||||||
|
};
|
||||||
|
|
||||||
|
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;
|
||||||
|
}]);
|
23
config_app/js/services/cookie-service.js
Normal file
23
config_app/js/services/cookie-service.js
Normal 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;
|
||||||
|
}]);
|
91
config_app/js/services/features-config.js
Normal file
91
config_app/js/services/features-config.js
Normal 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;
|
||||||
|
}]);
|
193
config_app/js/services/user-service.js
Normal file
193
config_app/js/services/user-service.js
Normal file
|
@ -0,0 +1,193 @@
|
||||||
|
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.currentUser = function() {
|
||||||
|
return userResponse;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update the user in the root scope.
|
||||||
|
userService.updateUserIn($rootScope);
|
||||||
|
|
||||||
|
return userService;
|
||||||
|
}]);
|
83
config_app/js/services/util-service.js
Normal file
83
config_app/js/services/util-service.js
Normal 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, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/'/g, "'");
|
||||||
|
|
||||||
|
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;
|
||||||
|
}]);
|
332
config_app/js/setup/setup.component.js
Normal file
332
config_app/js/setup/setup.component.js
Normal 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();
|
||||||
|
};
|
||||||
|
})();
|
311
config_app/js/setup/setup.html
Normal file
311
config_app/js/setup/setup.html
Normal 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>
|
2
config_app/static/lib/angular-file-upload.min.js
vendored
Normal file
2
config_app/static/lib/angular-file-upload.min.js
vendored
Normal 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)}}}])}();
|
41
config_app/templates/index.html
Normal file
41
config_app/templates/index.html
Normal file
|
@ -0,0 +1,41 @@
|
||||||
|
<!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-sanitize.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>
|
||||||
|
|
||||||
|
{% 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>
|
61
config_app/webpack.config.js
Normal file
61
config_app/webpack.config.js
Normal file
|
@ -0,0 +1,61 @@
|
||||||
|
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'),
|
||||||
|
path.resolve(__dirname, '../static/css/')
|
||||||
|
]
|
||||||
|
},
|
||||||
|
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;
|
|
@ -1,3 +1,4 @@
|
||||||
|
# TODO to extract the discovery stuff into a util at the top level and then use it both here and config_app discovery.py
|
||||||
""" API discovery information. """
|
""" API discovery information. """
|
||||||
|
|
||||||
import re
|
import re
|
||||||
|
|
17
local-config-app.sh
Executable file
17
local-config-app.sh
Executable file
|
@ -0,0 +1,17 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
cat << "EOF"
|
||||||
|
__ __
|
||||||
|
/ \ / \ ______ _ _ __ __ __ _____ ____ _ _ _____ _____ _____
|
||||||
|
/ /\ / /\ \ / __ \ | | | | / \ \ \ / / / ____| / __ \ | \ | | | ___| |_ _| / ____|
|
||||||
|
/ / / / \ \ | | | | | | | | / /\ \ \ / | | | | | | | \| | | |__ | | | | _
|
||||||
|
\ \ \ \ / / | |__| | | |__| | / ____ \ | | | |____ | |__| | | . ` | | __| _| |_ | |__| |
|
||||||
|
\ \/ \ \/ / \_ ___/ \____/ /_/ \_\ |_| \_____| \____/ |_| \_| |_| |_____| \_____|
|
||||||
|
\__/ \__/ \ \__
|
||||||
|
\___\ by Red Hat
|
||||||
|
|
||||||
|
Build, Store, and Distribute your Containers
|
||||||
|
|
||||||
|
|
||||||
|
EOF
|
||||||
|
|
||||||
|
goreman -basedir "config_app" start
|
|
@ -12,7 +12,11 @@
|
||||||
"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",
|
||||||
|
"build-config-app": "npm run clean-config-app && cd config_app && NODE_ENV=production webpack --progress"
|
||||||
},
|
},
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
|
|
|
@ -3,8 +3,8 @@
|
||||||
MODE="$1"
|
MODE="$1"
|
||||||
|
|
||||||
display_usage() {
|
display_usage() {
|
||||||
echo "This script takes one arguments."
|
echo "This script takes one argument."
|
||||||
echo -e "\nUsage: ${0} <interactive|batch|both>\n"
|
echo -e "\nUsage: ${0} <shell|config|interactive|batch|both>\n"
|
||||||
}
|
}
|
||||||
|
|
||||||
if [[ "${MODE}" = "help" ]]
|
if [[ "${MODE}" = "help" ]]
|
||||||
|
@ -32,6 +32,15 @@ EOF
|
||||||
venv/bin/python -m displayversion
|
venv/bin/python -m displayversion
|
||||||
|
|
||||||
case "$MODE" in
|
case "$MODE" in
|
||||||
|
"shell")
|
||||||
|
echo "Entering shell mode"
|
||||||
|
/bin/bash
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
"config")
|
||||||
|
echo "Entering config mode, only copying config-app entrypoints"
|
||||||
|
cp -r ${QUAYDIR}/config_app/init/service/* /etc/service
|
||||||
|
;;
|
||||||
"interactive")
|
"interactive")
|
||||||
echo "Copying $MODE files"
|
echo "Copying $MODE files"
|
||||||
cp -r ${QUAYCONF}/init/service/interactive/* /etc/service
|
cp -r ${QUAYCONF}/init/service/interactive/* /etc/service
|
||||||
|
|
|
@ -45,7 +45,7 @@ clean_cache() {
|
||||||
|
|
||||||
|
|
||||||
quay_run() {
|
quay_run() {
|
||||||
docker run --net=host -e TEST_DATABASE_URI -ti "${IMAGE}:${IMAGE_TAG}" "$@"
|
docker run --net=host --entrypoint "/bin/bash" -e TEST_DATABASE_URI -ti "${IMAGE}:${IMAGE_TAG}" -c "$*"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import time
|
import time
|
||||||
|
|
||||||
from boot import setup_jwt_proxy
|
# from boot import setup_jwt_proxy
|
||||||
from util.secscan.api import SecurityScannerAPI
|
from util.secscan.api import SecurityScannerAPI
|
||||||
from util.config.validators import BaseValidator, ConfigValidationException
|
from util.config.validators import BaseValidator, ConfigValidationException
|
||||||
|
|
||||||
|
@ -23,9 +23,9 @@ class SecurityScannerValidator(BaseValidator):
|
||||||
|
|
||||||
api = SecurityScannerAPI(config, None, server_hostname, client=client, skip_validation=True, uri_creator=uri_creator)
|
api = SecurityScannerAPI(config, None, server_hostname, client=client, skip_validation=True, uri_creator=uri_creator)
|
||||||
|
|
||||||
if not is_testing:
|
# if not is_testing:
|
||||||
# Generate a temporary Quay key to use for signing the outgoing requests.
|
# Generate a temporary Quay key to use for signing the outgoing requests.
|
||||||
setup_jwt_proxy()
|
# setup_jwt_proxy()
|
||||||
|
|
||||||
# We have to wait for JWT proxy to restart with the newly generated key.
|
# We have to wait for JWT proxy to restart with the newly generated key.
|
||||||
max_tries = 5
|
max_tries = 5
|
||||||
|
|
|
@ -3,7 +3,9 @@ import logging
|
||||||
from hashlib import sha1
|
from hashlib import sha1
|
||||||
|
|
||||||
from util.config.validators import BaseValidator, ConfigValidationException
|
from util.config.validators import BaseValidator, ConfigValidationException
|
||||||
from util.registry.torrent import jwt_from_infohash
|
# Temporarily removed because registry.torrent imports from app, add encoded_jwt back once extracted
|
||||||
|
# TODO(jschorr): extract app from following package and re-enable jwt_from_infohash in validator
|
||||||
|
# from util.registry.torrent import jwt_from_infohash
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
@ -31,8 +33,8 @@ class BittorrentValidator(BaseValidator):
|
||||||
'port': 80,
|
'port': 80,
|
||||||
}
|
}
|
||||||
|
|
||||||
encoded_jwt = jwt_from_infohash(params['info_hash'])
|
# encoded_jwt = jwt_from_infohash(params['info_hash'])
|
||||||
params['jwt'] = encoded_jwt
|
# params['jwt'] = encoded_jwt
|
||||||
|
|
||||||
resp = client.get(announce_url, timeout=5, params=params)
|
resp = client.get(announce_url, timeout=5, params=params)
|
||||||
logger.debug('Got tracker response: %s: %s', resp.status_code, resp.text)
|
logger.debug('Got tracker response: %s: %s', resp.status_code, resp.text)
|
||||||
|
|
Reference in a new issue