From eea5fe33912cd6e79c9acac35133fe60be5c933d Mon Sep 17 00:00:00 2001 From: Sam Chow Date: Thu, 9 Aug 2018 16:43:11 -0400 Subject: [PATCH] Add cycling deployments and updating config Add kube config with refactor to kube accessor Add tests for k8s accessor, some styling changes --- config_app/config_endpoints/api/__init__.py | 153 +++--- config_app/config_endpoints/api/suconfig.py | 469 +++++++++--------- config_app/config_endpoints/common.py | 3 +- .../config/TransientDirectoryProvider.py | 4 +- config_app/config_util/k8saccessor.py | 145 ++++++ config_app/config_util/k8sconfig.py | 47 ++ config_app/config_util/k8sinterface.py | 189 ------- .../config_util/test/test_k8saccessor.py | 63 +++ .../config-tool-service-nodeport.yml | 4 +- .../config-tool-serviceaccount.yml} | 0 ...config-tool-servicetoken-role-binding.yml} | 0 .../config-tool-servicetoken-role.yml} | 3 - .../k8s_templates/qe-config-tool.yml | 16 +- config_app/docs/kube_setup.md | 137 ++++- .../config-setup-app.component.html | 18 +- .../config-setup-app.component.ts | 6 +- .../download-tarball-modal.component.html | 6 +- .../download-tarball-modal.css | 5 + .../kube-deploy-modal.component.html | 39 +- .../kube-deploy-modal.component.ts | 45 +- .../kube-deploy-modal/kube-deploy-modal.css | 31 ++ .../static/css/config-setup-app-component.css | 5 +- config_app/templates/index.html | 2 +- 23 files changed, 830 insertions(+), 560 deletions(-) create mode 100644 config_app/config_util/k8saccessor.py create mode 100644 config_app/config_util/k8sconfig.py delete mode 100644 config_app/config_util/k8sinterface.py create mode 100644 config_app/config_util/test/test_k8saccessor.py rename config_app/{ => docs}/k8s_templates/config-tool-service-nodeport.yml (67%) rename config_app/{k8s_templates/config-tool-serviceaccount.yaml => docs/k8s_templates/config-tool-serviceaccount.yml} (100%) rename config_app/{k8s_templates/config-tool-servicetoken-role-binding.yaml => docs/k8s_templates/config-tool-servicetoken-role-binding.yml} (100%) rename config_app/{k8s_templates/config-tool-servicetoken-role.yaml => docs/k8s_templates/config-tool-servicetoken-role.yml} (93%) rename config_app/{ => docs}/k8s_templates/qe-config-tool.yml (58%) create mode 100644 config_app/js/components/kube-deploy-modal/kube-deploy-modal.css diff --git a/config_app/config_endpoints/api/__init__.py b/config_app/config_endpoints/api/__init__.py index 47e9b690d..118b68802 100644 --- a/config_app/config_endpoints/api/__init__.py +++ b/config_app/config_endpoints/api/__init__.py @@ -18,11 +18,11 @@ CROSS_DOMAIN_HEADERS = ['Authorization', 'Content-Type', 'X-Requested-With'] class ApiExceptionHandlingApi(Api): - pass + pass - @crossdomain(origin='*', headers=CROSS_DOMAIN_HEADERS) - def handle_error(self, error): - return super(ApiExceptionHandlingApi, self).handle_error(error) + @crossdomain(origin='*', headers=CROSS_DOMAIN_HEADERS) + def handle_error(self, error): + return super(ApiExceptionHandlingApi, self).handle_error(error) api = ApiExceptionHandlingApi() @@ -31,111 +31,112 @@ 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())) + """ 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 + def wrapper(api_resource): + if not api_resource: + return None - api_resource.registered = True - api.add_resource(api_resource, *urls, **kwargs) - return api_resource + api_resource.registered = True + api.add_resource(api_resource, *urls, **kwargs) + return api_resource - return wrapper + return wrapper class ApiResource(Resource): - registered = False - method_decorators = [] + registered = False + method_decorators = [] - def options(self): - return None, 200 + def options(self): + return None, 200 def add_method_metadata(name, value): - def modifier(func): - if func is None: - return None + 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 + if '__api_metadata' not in dir(func): + func.__api_metadata = {} + func.__api_metadata[name] = value + return func - return modifier + 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) + 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 + @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) + 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) + if app.config['TESTING']: + try: + validate(resp, schema) + except ValidationError as ex: + raise InvalidResponse(ex.message) - return resp - return wrapped - return wrapper + 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 + 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 def kubernetes_only(f): - @wraps(f) - def abort_if_not_kube(*args, **kwargs): - if not IS_KUBERNETES: - abort(400) + """ Aborts the request with a 400 if the app is not running on kubernetes """ + @wraps(f) + def abort_if_not_kube(*args, **kwargs): + if not IS_KUBERNETES: + abort(400) - return f(*args, **kwargs) - return abort_if_not_kube + return f(*args, **kwargs) + return abort_if_not_kube nickname = partial(add_method_metadata, 'nickname') diff --git a/config_app/config_endpoints/api/suconfig.py b/config_app/config_endpoints/api/suconfig.py index 9c59f38fe..9c44f455f 100644 --- a/config_app/config_endpoints/api/suconfig.py +++ b/config_app/config_endpoints/api/suconfig.py @@ -5,8 +5,8 @@ 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, kubernetes_only from config_app.c_app import (app, config_provider, superusers, ip_resolver, - instance_keys, INIT_SCRIPTS_LOCATION, IS_KUBERNETES) -from config_app.config_util.k8sinterface import KubernetesAccessInterface, kubernetes_access_instance + instance_keys, INIT_SCRIPTS_LOCATION) +from config_app.config_util.k8saccessor import KubernetesAccessorSingleton from data.database import configure from data.runmigration import run_alembic_migration @@ -17,293 +17,316 @@ logger = logging.getLogger(__name__) def database_is_valid(): - """ Returns whether the database, as configured, is valid. """ - return model.is_valid() + """ Returns whether the database, as configured, is valid. """ + return model.is_valid() def database_has_users(): - """ Returns whether the database has any users defined. """ - return model.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' - }, - }, + """ 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('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. + config_object = request.get_json()['config'] + hostname = request.get_json()['hostname'] - @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. - 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) - # 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) - # Write the configuration changes to the config override file. - config_provider.save_config(config_object) - - return { - 'exists': True, - 'config': config_object - } + return { + 'exists': True, + 'config': config_object + } @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. """ + """ 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 there is no config file, we need to setup the database. - if not config_provider.config_exists(): - return { - 'status': 'config-db' - } + # 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' - } + # 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' - } + 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 __init__(self): + super(_AlembicLogHandler, self).__init__() + self.records = [] - def emit(self, record): - self.records.append({ - 'level': record.levelname, - 'message': record.getMessage() - }) + 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()) + """ 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'] - db_uri = app.config['DB_URI'] + configure(combined) + app.config['DB_URI'] = combined['DB_URI'] + db_uri = app.config['DB_URI'] - log_handler = _AlembicLogHandler() + log_handler = _AlembicLogHandler() - try: - run_alembic_migration(db_uri, log_handler, setup_app=False) - except Exception as ex: - return { - 'error': str(ex) - } + try: + run_alembic_migration(db_uri, log_handler, setup_app=False) + except Exception as ex: + return { + 'error': str(ex) + } - return { - 'logs': log_handler.records - } + return { + 'logs': log_handler.records + } - abort(403) + 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' - }, - }, + """ 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. """ + @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'] + # 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) + # 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) + # 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) + # 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) + # 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 - } + return { + 'status': True + } - abort(403) + abort(403) @resource('/v1/superuser/config/validate/') 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' - } - }, + """ 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. - config = request.get_json()['config'] - validator_context = ValidatorContext.from_app(app, config, request.get_json().get('password', ''), - instance_keys=instance_keys, - ip_resolver=ip_resolver, - config_provider=config_provider, - init_scripts_location=INIT_SCRIPTS_LOCATION) + @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. + config = request.get_json()['config'] + validator_context = ValidatorContext.from_app(app, config, request.get_json().get('password', ''), + instance_keys=instance_keys, + ip_resolver=ip_resolver, + config_provider=config_provider, + init_scripts_location=INIT_SCRIPTS_LOCATION) - return validate_service_for_config(service, validator_context) + return validate_service_for_config(service, validator_context) @resource('/v1/kubernetes/deployments/') class SuperUserKubernetesDeployment(ApiResource): - """ Resource for fetching the status of config files and overriding them. """ - @kubernetes_only - @nickname('scGetNumDeployments') - def get(self): - return kubernetes_access_instance.get_qe_deployments() + """ Resource for the getting the status of Quay Enterprise deployments and cycling them """ + schemas = { + 'ValidateDeploymentNames': { + 'type': 'object', + 'description': 'Validates deployment names for cycling', + 'required': [ + 'deploymentNames' + ], + 'properties': { + 'deploymentNames': { + 'type': 'array', + 'description': 'The names of the deployments to cycle' + }, + }, + } + } + + @kubernetes_only + @nickname('scGetNumDeployments') + def get(self): + return KubernetesAccessorSingleton.get_instance().get_qe_deployments() + + @kubernetes_only + @nickname('scCycleQEDeployments') + def put(self): + deployment_names = request.get_json()['deploymentNames'] + return KubernetesAccessorSingleton.get_instance().cycle_qe_deployments(deployment_names) + @resource('/v1/superuser/config/kubernetes') class SuperUserKubernetesConfiguration(ApiResource): - """ Resource for fetching the status of config files and overriding them. """ - @kubernetes_only - @nickname('scDeployConfiguration') - def post(self): - return config_provider.save_configuration_to_kubernetes() + """ Resource for saving the config files to kubernetes secrets. """ + @kubernetes_only + @nickname('scDeployConfiguration') + def post(self): + return config_provider.save_configuration_to_kubernetes() @resource('/v1/superuser/config/file/') class SuperUserConfigFile(ApiResource): - """ Resource for fetching the status of config files and overriding them. """ - @nickname('scConfigFileExists') - def get(self, filename): - """ Returns whether the configuration file with the given name exists. """ - if not is_valid_config_upload_filename(filename): - abort(404) + """ Resource for fetching the status of config files and overriding them. """ + @nickname('scConfigFileExists') + def get(self, filename): + """ Returns whether the configuration file with the given name exists. """ + if not is_valid_config_upload_filename(filename): + abort(404) - return { - 'exists': config_provider.volume_file_exists(filename) - } + return { + 'exists': config_provider.volume_file_exists(filename) + } - @nickname('scUpdateConfigFile') - def post(self, filename): - """ Updates the configuration file with the given name. """ - if not is_valid_config_upload_filename(filename): - abort(404) + @nickname('scUpdateConfigFile') + def post(self, filename): + """ Updates the configuration file with the given name. """ + if not is_valid_config_upload_filename(filename): + abort(404) - # Note: This method can be called before the configuration exists - # to upload the database SSL cert. - uploaded_file = request.files['file'] - if not uploaded_file: - abort(400) + # Note: This method can be called before the configuration exists + # to upload the database SSL cert. + uploaded_file = request.files['file'] + if not uploaded_file: + abort(400) - config_provider.save_volume_file(filename, uploaded_file) - return { - 'status': True - } + config_provider.save_volume_file(filename, uploaded_file) + return { + 'status': True + } diff --git a/config_app/config_endpoints/common.py b/config_app/config_endpoints/common.py index 66a8dc79c..b3cc961ad 100644 --- a/config_app/config_endpoints/common.py +++ b/config_app/config_endpoints/common.py @@ -9,6 +9,7 @@ from config import frontend_visible_config from config_app.c_app import app, IS_KUBERNETES from config_app._init_config import ROOT_DIR +from config_app.config_util.k8sconfig import get_k8s_namespace def truthy_bool(param): @@ -49,7 +50,7 @@ def render_page_template(name, route_data=None, js_bundle_name=DEFAULT_JS_BUNDLE route_data=route_data, main_scripts=main_scripts, config_set=frontend_visible_config(app.config), - is_kubernetes=IS_KUBERNETES, + kubernetes_namespace=IS_KUBERNETES and get_k8s_namespace(), **kwargs) resp = make_response(contents) diff --git a/config_app/config_util/config/TransientDirectoryProvider.py b/config_app/config_util/config/TransientDirectoryProvider.py index 15bb77738..bf2932fd7 100644 --- a/config_app/config_util/config/TransientDirectoryProvider.py +++ b/config_app/config_util/config/TransientDirectoryProvider.py @@ -2,7 +2,7 @@ import os from backports.tempfile import TemporaryDirectory from config_app.config_util.config.fileprovider import FileConfigProvider -from config_app.config_util.k8sinterface import kubernetes_access_instance +from config_app.config_util.k8saccessor import KubernetesAccessorSingleton class TransientDirectoryProvider(FileConfigProvider): @@ -41,6 +41,6 @@ class TransientDirectoryProvider(FileConfigProvider): for name in os.listdir(config_path): file_path = os.path.join(self.config_volume, name) - kubernetes_access_instance.save_file_as_secret(name, file_path) + KubernetesAccessorSingleton.get_instance().save_file_as_secret(name, file_path) return 200 diff --git a/config_app/config_util/k8saccessor.py b/config_app/config_util/k8saccessor.py new file mode 100644 index 000000000..ae612fe5f --- /dev/null +++ b/config_app/config_util/k8saccessor.py @@ -0,0 +1,145 @@ +import logging +import json +import base64 +import datetime + +from requests import Request, Session + +from config_app.config_util.k8sconfig import KubernetesConfig + +logger = logging.getLogger(__name__) + +QE_DEPLOYMENT_LABEL = 'quay-enterprise-component' + +class KubernetesAccessorSingleton(object): + """ Singleton allowing access to kubernetes operations """ + _instance = None + + def __init__(self, kube_config=None): + self.kube_config = kube_config + if kube_config is None: + self.kube_config = KubernetesConfig.from_env() + + KubernetesAccessorSingleton._instance = self + + @classmethod + def get_instance(cls, kube_config=None): + """ + Singleton getter implementation, returns the instance if one exists, otherwise creates the + instance and ties it to the class. + :return: KubernetesAccessorSingleton + """ + if cls._instance is None: + return cls(kube_config) + + return cls._instance + + def save_file_as_secret(self, name, file_path): + with open(file_path) as f: + value = f.read() + self._update_secret_file(name, value) + + def get_qe_deployments(self): + """" + Returns all deployments matching the label selector provided in the KubeConfig + """ + deployment_selector_url = 'namespaces/%s/deployments?labelSelector=%s%%3D%s' % ( + self.kube_config.qe_namespace, QE_DEPLOYMENT_LABEL, self.kube_config.qe_deployment_selector + ) + + response = self._execute_k8s_api('GET', deployment_selector_url, api_prefix='apis/extensions/v1beta1') + if response.status_code != 200: + return None + return json.loads(response.text) + + def cycle_qe_deployments(self, deployment_names): + """" + Triggers a rollout of all desired deployments in the qe namespace + """ + + for name in deployment_names: + logger.debug('Cycling deployment %s', name) + deployment_url = 'namespaces/%s/deployments/%s' % (self.kube_config.qe_namespace, name) + + # There is currently no command to simply rolling restart all the pods: https://github.com/kubernetes/kubernetes/issues/13488 + # Instead, we modify the template of the deployment with a dummy env variable to trigger a cycle of the pods + # (based off this comment: https://github.com/kubernetes/kubernetes/issues/13488#issuecomment-240393845) + self._assert_success(self._execute_k8s_api('PATCH', deployment_url, { + 'spec': { + 'template': { + 'spec': { + 'containers': [{ + 'name': 'quay-enterprise-app', 'env': [{ + 'name': 'RESTART_TIME', + 'value': str(datetime.datetime.now()) + }] + }] + } + } + } + }, api_prefix='apis/extensions/v1beta1', content_type='application/strategic-merge-patch+json')) + + + def _assert_success(self, response): + if response.status_code != 200: + logger.error('Kubernetes API call failed with response: %s => %s', response.status_code, + response.text) + raise Exception('Kubernetes API call failed: %s' % response.text) + + def _update_secret_file(self, relative_file_path, value=None): + if '/' in relative_file_path: + raise Exception('Expected path from get_volume_path, but found slashes') + + # Check first that the namespace for Quay Enterprise exists. If it does not, report that + # as an error, as it seems to be a common issue. + namespace_url = 'namespaces/%s' % (self.kube_config.qe_namespace) + response = self._execute_k8s_api('GET', namespace_url) + if response.status_code // 100 != 2: + msg = 'A Kubernetes namespace with name `%s` must be created to save config' % self.kube_config.qe_namespace + raise Exception(msg) + + # Check if the secret exists. If not, then we create an empty secret and then update the file + # inside. + secret_url = 'namespaces/%s/secrets/%s' % (self.kube_config.qe_namespace, self.kube_config.qe_config_secret) + secret = self._lookup_secret() + if secret is None: + self._assert_success(self._execute_k8s_api('POST', secret_url, { + "kind": "Secret", + "apiVersion": "v1", + "metadata": { + "name": self.kube_config.qe_config_secret + }, + "data": {} + })) + + # Update the secret to reflect the file change. + secret['data'] = secret.get('data', {}) + + if value is not None: + secret['data'][relative_file_path] = base64.b64encode(value) + else: + secret['data'].pop(relative_file_path) + + self._assert_success(self._execute_k8s_api('PUT', secret_url, secret)) + + def _lookup_secret(self): + secret_url = 'namespaces/%s/secrets/%s' % (self.kube_config.qe_namespace, self.kube_config.qe_config_secret) + response = self._execute_k8s_api('GET', secret_url) + if response.status_code != 200: + return None + return json.loads(response.text) + + def _execute_k8s_api(self, method, relative_url, data=None, api_prefix='api/v1', content_type='application/json'): + headers = { + 'Authorization': 'Bearer ' + self.kube_config.service_account_token + } + + if data: + headers['Content-Type'] = content_type + + data = json.dumps(data) if data else None + session = Session() + url = 'https://%s/%s/%s' % (self.kube_config.api_host, api_prefix, relative_url) + + request = Request(method, url, data=data, headers=headers) + return session.send(request.prepare(), verify=False, timeout=2) diff --git a/config_app/config_util/k8sconfig.py b/config_app/config_util/k8sconfig.py new file mode 100644 index 000000000..3a67856be --- /dev/null +++ b/config_app/config_util/k8sconfig.py @@ -0,0 +1,47 @@ +import os + +SERVICE_ACCOUNT_TOKEN_PATH = '/var/run/secrets/kubernetes.io/serviceaccount/token' + +DEFAULT_QE_NAMESPACE = 'quay-enterprise' +DEFAULT_QE_CONFIG_SECRET = 'quay-enterprise-config-secret' + +# The name of the quay enterprise deployment (not config app) that is used to query & rollout +DEFAULT_QE_DEPLOYMENT_SELECTOR = 'app' + +def get_k8s_namespace(): + return os.environ.get('QE_K8S_NAMESPACE', DEFAULT_QE_NAMESPACE) + +class KubernetesConfig(object): + def __init__(self, api_host='', service_account_token=SERVICE_ACCOUNT_TOKEN_PATH, + qe_namespace=DEFAULT_QE_NAMESPACE, + qe_config_secret=DEFAULT_QE_CONFIG_SECRET, + qe_deployment_selector=DEFAULT_QE_DEPLOYMENT_SELECTOR): + self.api_host = api_host + self.qe_namespace = qe_namespace + self.qe_config_secret = qe_config_secret + self.qe_deployment_selector = qe_deployment_selector + self.service_account_token = service_account_token + + @classmethod + def from_env(cls): + # Load the service account token from the local store. + if not os.path.exists(SERVICE_ACCOUNT_TOKEN_PATH): + raise Exception('Cannot load Kubernetes service account token') + + with open(SERVICE_ACCOUNT_TOKEN_PATH, 'r') as f: + service_token = f.read() + + api_host=os.environ.get('KUBERNETES_SERVICE_HOST', '') + port = os.environ.get('KUBERNETES_SERVICE_PORT') + if port: + api_host += ':' + port + + qe_namespace = get_k8s_namespace() + qe_config_secret = os.environ.get('QE_K8S_CONFIG_SECRET', DEFAULT_QE_CONFIG_SECRET) + qe_deployment_selector = os.environ.get('QE_DEPLOYMENT_SELECTOR', DEFAULT_QE_DEPLOYMENT_SELECTOR) + + return cls(api_host=api_host, service_account_token=service_token, qe_namespace=qe_namespace, + qe_config_secret=qe_config_secret, qe_deployment_selector=qe_deployment_selector) + + + diff --git a/config_app/config_util/k8sinterface.py b/config_app/config_util/k8sinterface.py deleted file mode 100644 index 41522fdfa..000000000 --- a/config_app/config_util/k8sinterface.py +++ /dev/null @@ -1,189 +0,0 @@ -import os -import logging -import json -import base64 -import time - -from cStringIO import StringIO -from requests import Request, Session - - -logger = logging.getLogger(__name__) - -KUBERNETES_API_HOST = os.environ.get('KUBERNETES_SERVICE_HOST', '') -port = os.environ.get('KUBERNETES_SERVICE_PORT') -if port: - KUBERNETES_API_HOST += ':' + port - -SERVICE_ACCOUNT_TOKEN_PATH = '/var/run/secrets/kubernetes.io/serviceaccount/token' - -QE_NAMESPACE = os.environ.get('QE_K8S_NAMESPACE', 'quay-enterprise') -QE_CONFIG_SECRET = os.environ.get('QE_K8S_CONFIG_SECRET', 'quay-enterprise-config-secret') - -# The name of the quay enterprise deployment (not config app) that is used to query & rollout -QE_DEPLOYMENT_SELECTOR = os.environ.get('QE_DEPLOYMENT_SELECTOR', 'app') - -class KubernetesAccessInterface: - """ Implementation of the config provider that reads and writes configuration - data from a Kubernetes Secret. """ - def __init__(self, api_host=None, service_account_token_path=None): - service_account_token_path = service_account_token_path or SERVICE_ACCOUNT_TOKEN_PATH - api_host = api_host or KUBERNETES_API_HOST - - # Load the service account token from the local store. - if not os.path.exists(service_account_token_path): - raise Exception('Cannot load Kubernetes service account token') - - with open(service_account_token_path, 'r') as f: - self._service_token = f.read() - - self._api_host = api_host - - # def volume_file_exists(self, relative_file_path): - # if '/' in relative_file_path: - # raise Exception('Expected path from get_volume_path, but found slashes') - # - # # NOTE: Overridden because we don't have subdirectories, which aren't supported - # # in Kubernetes secrets. - # secret = self._lookup_secret() - # if not secret or not secret.get('data'): - # return False - # return relative_file_path in secret['data'] - # - # def list_volume_directory(self, path): - # # NOTE: Overridden because we don't have subdirectories, which aren't supported - # # in Kubernetes secrets. - # secret = self._lookup_secret() - # - # if not secret: - # return [] - # - # paths = [] - # for filename in secret.get('data', {}): - # if filename.startswith(path): - # paths.append(filename[len(path) + 1:]) - # return paths - # - # def save_config(self, config_obj): - # self._update_secret_file(self.yaml_filename, get_yaml(config_obj)) - # - # def remove_volume_file(self, relative_file_path): - # try: - # self._update_secret_file(relative_file_path, None) - # except IOError as ioe: - # raise CannotWriteConfigException(str(ioe)) - # - # def save_volume_file(self, flask_file, relative_file_path): - # # Write the file to a temp location. - # buf = StringIO() - # try: - # try: - # flask_file.save(buf) - # except IOError as ioe: - # raise Exception(str(ioe)) - # - # self._update_secret_file(relative_file_path, buf.getvalue()) - # finally: - # buf.close() - - def save_file_as_secret(self, name, file_path): - with open(file_path) as f: - value = f.read() - self._update_secret_file(name, value) - - def get_qe_deployments(self): - pod_selector_url = 'namespaces/%s/deployments?labelSelector=quay-enterprise-component%%3D%s' % (QE_NAMESPACE, QE_DEPLOYMENT_SELECTOR) - response = self._execute_k8s_api('GET', pod_selector_url, api_prefix='apis/extensions/v1beta1') - if response.status_code != 200: - return None - return json.loads(response.text) - - def _assert_success(self, response): - if response.status_code != 200: - logger.error('Kubernetes API call failed with response: %s => %s', response.status_code, - response.text) - raise Exception('Kubernetes API call failed: %s' % response.text) - - def _update_secret_file(self, relative_file_path, value=None): - if '/' in relative_file_path: - raise Exception('Expected path from get_volume_path, but found slashes') - - # Check first that the namespace for Quay Enterprise exists. If it does not, report that - # as an error, as it seems to be a common issue. - namespace_url = 'namespaces/%s' % (QE_NAMESPACE) - response = self._execute_k8s_api('GET', namespace_url) - if response.status_code // 100 != 2: - msg = 'A Kubernetes namespace with name `%s` must be created to save config' % QE_NAMESPACE - raise Exception(msg) - - # Check if the secret exists. If not, then we create an empty secret and then update the file - # inside. - secret_url = 'namespaces/%s/secrets/%s' % (QE_NAMESPACE, QE_CONFIG_SECRET) - secret = self._lookup_secret() - if secret is None: - self._assert_success(self._execute_k8s_api('POST', secret_url, { - "kind": "Secret", - "apiVersion": "v1", - "metadata": { - "name": QE_CONFIG_SECRET - }, - "data": {} - })) - - # Update the secret to reflect the file change. - secret['data'] = secret.get('data', {}) - - if value is not None: - secret['data'][relative_file_path] = base64.b64encode(value) - else: - secret['data'].pop(relative_file_path) - - self._assert_success(self._execute_k8s_api('PUT', secret_url, secret)) - - # Wait until the local mounted copy of the secret has been updated, as - # this is an eventual consistency operation, but the caller expects immediate - # consistency. - - # while True: - # matching_files = set() - # for secret_filename, encoded_value in secret['data'].iteritems(): - # expected_value = base64.b64decode(encoded_value) - # try: - # with self.get_volume_file(secret_filename) as f: - # contents = f.read() - # - # if contents == expected_value: - # matching_files.add(secret_filename) - # - # except IOError: - # continue - # - # if matching_files == set(secret['data'].keys()): - # break - # - # # Sleep for a second and then try again. - # time.sleep(1) - - def _lookup_secret(self): - secret_url = 'namespaces/%s/secrets/%s' % (QE_NAMESPACE, QE_CONFIG_SECRET) - response = self._execute_k8s_api('GET', secret_url) - if response.status_code != 200: - return None - return json.loads(response.text) - - def _execute_k8s_api(self, method, relative_url, data=None, api_prefix='api/v1'): - headers = { - 'Authorization': 'Bearer ' + self._service_token - } - - if data: - headers['Content-Type'] = 'application/json' - - data = json.dumps(data) if data else None - session = Session() - url = 'https://%s/%s/%s' % (self._api_host, api_prefix, relative_url) - - request = Request(method, url, data=data, headers=headers) - return session.send(request.prepare(), verify=False, timeout=2) - -kubernetes_access_instance = KubernetesAccessInterface() \ No newline at end of file diff --git a/config_app/config_util/test/test_k8saccessor.py b/config_app/config_util/test/test_k8saccessor.py new file mode 100644 index 000000000..526211d2f --- /dev/null +++ b/config_app/config_util/test/test_k8saccessor.py @@ -0,0 +1,63 @@ +import pytest +import re + +from httmock import urlmatch, HTTMock, response + +from config_app.config_util.k8saccessor import KubernetesAccessorSingleton +from config_app.config_util.k8sconfig import KubernetesConfig + +@pytest.mark.parametrize('kube_config, expected_api, expected_query', [ + ({'api_host':'www.customhost.com'}, + '/apis/extensions/v1beta1/namespaces/quay-enterprise/deployments', 'labelSelector=quay-enterprise-component%3Dapp'), + + ({'api_host':'www.customhost.com', 'qe_deployment_selector':'custom-selector'}, + '/apis/extensions/v1beta1/namespaces/quay-enterprise/deployments', 'labelSelector=quay-enterprise-component%3Dcustom-selector'), + + ({'api_host':'www.customhost.com', 'qe_namespace':'custom-namespace'}, + '/apis/extensions/v1beta1/namespaces/custom-namespace/deployments', 'labelSelector=quay-enterprise-component%3Dapp'), + + ({'api_host':'www.customhost.com', 'qe_namespace':'custom-namespace', 'qe_deployment_selector':'custom-selector'}, + '/apis/extensions/v1beta1/namespaces/custom-namespace/deployments', 'labelSelector=quay-enterprise-component%3Dcustom-selector'), +]) +def test_get_qe_deployments(kube_config, expected_api, expected_query): + config = KubernetesConfig(**kube_config) + url_hit = [False] + + @urlmatch(netloc=r'www.customhost.com') + def handler(request, _): + assert request.path == expected_api + assert request.query == expected_query + url_hit[0] = True + return response(200, '{}') + + with HTTMock(handler): + KubernetesAccessorSingleton._instance = None + assert KubernetesAccessorSingleton.get_instance(config).get_qe_deployments() is not None + + assert url_hit[0] + +@pytest.mark.parametrize('kube_config, deployment_names, expected_api_hits', [ + ({'api_host':'www.customhost.com'}, [], []), + ({'api_host':'www.customhost.com'}, ['myDeployment'], ['/apis/extensions/v1beta1/namespaces/quay-enterprise/deployments/myDeployment']), + ({'api_host':'www.customhost.com', 'qe_namespace':'custom-namespace'}, + ['myDeployment', 'otherDeployment'], + ['/apis/extensions/v1beta1/namespaces/custom-namespace/deployments/myDeployment', '/apis/extensions/v1beta1/namespaces/custom-namespace/deployments/otherDeployment']), +]) +def test_cycle_qe_deployments(kube_config, deployment_names, expected_api_hits): + KubernetesAccessorSingleton._instance = None + + config = KubernetesConfig(**kube_config) + url_hit = [False] * len(expected_api_hits) + i = [0] + + @urlmatch(netloc=r'www.customhost.com', method='PATCH') + def handler(request, _): + assert request.path == expected_api_hits[i[0]] + url_hit[i[0]] = True + i[0] += 1 + return response(200, '{}') + + with HTTMock(handler): + KubernetesAccessorSingleton.get_instance(config).cycle_qe_deployments(deployment_names) + + assert all(url_hit) diff --git a/config_app/k8s_templates/config-tool-service-nodeport.yml b/config_app/docs/k8s_templates/config-tool-service-nodeport.yml similarity index 67% rename from config_app/k8s_templates/config-tool-service-nodeport.yml rename to config_app/docs/k8s_templates/config-tool-service-nodeport.yml index 3e37e7b6b..313ad2b1c 100644 --- a/config_app/k8s_templates/config-tool-service-nodeport.yml +++ b/config_app/docs/k8s_templates/config-tool-service-nodeport.yml @@ -9,6 +9,6 @@ spec: - protocol: TCP port: 443 targetPort: 443 - nodePort: 30080 + nodePort: 30090 selector: - quay-enterprise-component: app # TODO: change me to config tool selectgor + quay-enterprise-component: config-tool diff --git a/config_app/k8s_templates/config-tool-serviceaccount.yaml b/config_app/docs/k8s_templates/config-tool-serviceaccount.yml similarity index 100% rename from config_app/k8s_templates/config-tool-serviceaccount.yaml rename to config_app/docs/k8s_templates/config-tool-serviceaccount.yml diff --git a/config_app/k8s_templates/config-tool-servicetoken-role-binding.yaml b/config_app/docs/k8s_templates/config-tool-servicetoken-role-binding.yml similarity index 100% rename from config_app/k8s_templates/config-tool-servicetoken-role-binding.yaml rename to config_app/docs/k8s_templates/config-tool-servicetoken-role-binding.yml diff --git a/config_app/k8s_templates/config-tool-servicetoken-role.yaml b/config_app/docs/k8s_templates/config-tool-servicetoken-role.yml similarity index 93% rename from config_app/k8s_templates/config-tool-servicetoken-role.yaml rename to config_app/docs/k8s_templates/config-tool-servicetoken-role.yml index 8934cae9d..2e48614ab 100644 --- a/config_app/k8s_templates/config-tool-servicetoken-role.yaml +++ b/config_app/docs/k8s_templates/config-tool-servicetoken-role.yml @@ -25,8 +25,5 @@ rules: resources: - deployments verbs: - - get - list - - put - patch - - update diff --git a/config_app/k8s_templates/qe-config-tool.yml b/config_app/docs/k8s_templates/qe-config-tool.yml similarity index 58% rename from config_app/k8s_templates/qe-config-tool.yml rename to config_app/docs/k8s_templates/qe-config-tool.yml index 225a5257c..a3fab21bd 100644 --- a/config_app/k8s_templates/qe-config-tool.yml +++ b/config_app/docs/k8s_templates/qe-config-tool.yml @@ -2,19 +2,19 @@ apiVersion: extensions/v1beta1 kind: Deployment metadata: namespace: quay-enterprise - name: quay-enterprise-app + name: quay-enterprise-config-tool labels: - quay-enterprise-component: app # TODO: change to config tool selector + quay-enterprise-component: config-tool spec: replicas: 1 selector: matchLabels: - quay-enterprise-component: app # TODO: change to config tool selector + quay-enterprise-component: config-tool template: metadata: namespace: quay-enterprise labels: - quay-enterprise-component: app # TODO: change to config tool selector + quay-enterprise-component: config-tool spec: serviceAccountName: qe-config-tool-serviceaccount volumes: @@ -22,15 +22,9 @@ spec: secret: secretName: quay-enterprise-config-secret containers: - - name: quay-enterprise-app + - name: quay-enterprise-config-tool image: config-app:latest # TODO: change to reference to quay image? imagePullPolicy: IfNotPresent # enable when testing with minikube args: ["config"] ports: - containerPort: 80 - volumeMounts: - - name: configvolume - readOnly: false - mountPath: /conf/stack - imagePullSecrets: - - name: blueish-pull-secret diff --git a/config_app/docs/kube_setup.md b/config_app/docs/kube_setup.md index 7b98dfb07..b6ecd88b1 100644 --- a/config_app/docs/kube_setup.md +++ b/config_app/docs/kube_setup.md @@ -1,9 +1,122 @@ -# Configuring Quay on Kubernetes +# Quay Enterprise Installation on Kubernetes + +This guide walks through the deployment of [Quay Enterprise][quay-enterprise-tour] onto a Kubernetes cluster. +After completing the steps in this guide, a deployer will have a functioning instance of Quay Enterprise orchestrated as a Kubernetes service on a cluster, and will be able to access the Quay Enterprise Setup tool with a browser to complete configuration of image repositories, builders, and users. + +[quay-enterprise-tour]: https://quay.io/tour/enterprise + +## Prerequisites + +A PostgreSQL database must be available for Quay Enterprise metadata storage. +We currently recommend running this database server outside of the cluster. + +## Download Kubernetes Configuration Files + +Visit the [RedHat Documentation][RedHat-documentation] and download the pre-formatted pull secret, under "Account Assets". There are several formats of the secret, be sure to download the "dockercfg" format resulting in a `config.json` file. This pull secret is used to download the Quay Enterprise containers. + +This will be used later in the guide. + +[RedHat-documentation]: https://access.redhat.com/documentation/en-us/ + +Next, download each of the following files to your workstation, placing them alongside your pull secret: + +- [quay-enterprise-namespace.yml](files/quay-enterprise-namespace.yml) +- [quay-enterprise-config-secret.yml](files/quay-enterprise-config-secret.yml) +- [quay-enterprise-redis.yml](files/quay-enterprise-redis.yml) +- [quay-enterprise-app-rc.yml](files/quay-enterprise-app-rc.yml) +- [quay-enterprise-service-nodeport.yml](files/quay-enterprise-service-nodeport.yml) +- [quay-enterprise-service-loadbalancer.yml](files/quay-enterprise-service-loadbalancer.yml) + +## Role Based Access Control + +Quay Enterprise has native Kubernetes integrations. These integrations require Service Account to have access to Kubernetes API. When Kubernetes RBAC is enabled, Role Based Access Control policy manifests also have to be deployed. + +Kubernetes API has minor changes between versions 1.4 and 1.5, Download appropiate versions of Role Based Access Control (RBAC) Policies. + +### Kubernetes v1.6.x and later RBAC Policies + +- [quay-servicetoken-role.yaml](files/quay-servicetoken-role-k8s1-6.yaml) +- [quay-servicetoken-role-binding.yaml](files/quay-servicetoken-role-binding-k8s1-6.yaml) -... include old setup here, with extra steps: +## Deploy to Kubernetes -# Configuring RBAC for the configuration tool +All Kubernetes objects will be deployed under the "quay-enterprise" namespace. +The first step is to create this namespace: + +```sh +kubectl create -f quay-enterprise-namespace.yml +``` + +Next, add your pull secret to Kubernetes (make sure you specify the correct path to `config.json`): + +```sh +kubectl create secret generic coreos-pull-secret --from-file=".dockerconfigjson=config.json" --type='kubernetes.io/dockerconfigjson' --namespace=quay-enterprise +``` + +### Kubernetes v1.6.x and later : Deploy RBAC Policies + +```sh +kubectl create -f quay-servicetoken-role-k8s1-6.yaml +kubectl create -f quay-servicetoken-role-binding-k8s1-6.yaml +``` + +### Deploy Quay Enterprise objects + +Finally, the remaining Kubernetes objects can be deployed onto Kubernetes: + +```sh +kubectl create -f quay-enterprise-config-secret.yml -f quay-enterprise-redis.yml -f quay-enterprise-app-rc.yml +``` + +## Expose via Kubernetes Service + +In order to access Quay Enterprise, a user must route to it through a Kubernetes Service. +It is up to the deployer to decide which Service type is appropriate for their use case: a [LoadBalancer](http://kubernetes.io/docs/user-guide/services/#type-loadbalancer) or a [NodePort](http://kubernetes.io/docs/user-guide/services/#type-nodeport). + +A LoadBalancer is recommended if the Kubernetes cluster is integrated with a cloud provider, otherwise a NodePort will suffice. +Along with this guide are examples of this service. + +### LoadBalancer + +Using the sample provided, a LoadBalancer Kubernetes Service can be created like so: + +```sh +kubectl create -f quay-enterprise-service-loadbalancer.yml +``` + +kubectl can be used to find the externally-accessible URL of the quay-enterprise service: + +```sh +kubectl describe services quay-enterprise --namespace=quay-enterprise +``` + +### NodePort + +Using the sample provided, a NodePort Kubernetes Service can be created like so: + +```sh +kubectl create -f quay-enterprise-service-nodeport.yml +``` + +By default, the quay-enterprise service will be available on port 30080 on every node in the Kubernetes cluster. +If this port conflicts with an existing Kubernetes Service, simply modify the sample configuration file and change the value of NodePort. + +## Continue with Quay Enterprise Setup + +All that remains is to configure Quay Enterprise itself through the configuration tool. + +Download the following files to your workstation: + +- [config-tool-service-nodeport.yml](k8s_templates/config-tool-service-nodeport.yml) +- [config-tool-serviceaccount.yml](k8s_templates/config-tool-serviceaccount.yml) +- [config-tool-servicetoken-role.yml](k8s_templates/config-tool-servicetoken-role.yml) +- [config-tool-servicetoken-role-binding.yml](k8s_templates/config-tool-servicetoken-role-binding.yml) +- [qe-config-tool.yml](k8s_templates/qe-config-tool.yml) + +### Configuring RBAC for the configuration tool + +Apply the following policies to allow the config tool to make changes to the Q.E. deployment: ```bash kubectl apply -f config-tool-serviceaccount.yaml ``` @@ -13,14 +126,18 @@ kubectl apply -f config-tool-servicetoken-role.yaml ```bash kubectl apply -f config-tool-servicetoken-role-binding.yaml ``` + +### Deploy Config Tool + +Deploy the configuration tool and route a service to it: ```bash -kubectl apply -f qe-config-tool.yml +kubectl apply -f qe-config-tool.yml -f config-tool-service-nodeport.yml ``` -Make a nodeservice for it: -```bash -kubectl apply -f config-tool-service-nodeport.yml -``` - - +By default, the config-tool service will be available on port 30090 on every node in the Kubernetes cluster. +Similar to the Quay application service, if this port conflicts with an existing Kubernetes Service, simply modify the sample configuration file and change the value of NodePort. +Once at the Quay Enterprise setup UI, follow the setup instructions to finalize your installation. +## Using the Configuration Tool +Click on "Start New Configuration for this Cluster", and follow the instructions to create your configuration, downloading and saving it (to load as a backup or if you ever wish to change your settings). +You will also be able to deploy the configuration to all instances by hitting "Deploy". Allow for a minute for the Quay instances to cycle the pods, and your configuration will be enacted once the pods have started. diff --git a/config_app/js/components/config-setup-app/config-setup-app.component.html b/config_app/js/components/config-setup-app/config-setup-app.component.html index 7dc314a2b..16dc0940f 100644 --- a/config_app/js/components/config-setup-app/config-setup-app.component.html +++ b/config_app/js/components/config-setup-app/config-setup-app.component.html @@ -1,4 +1,4 @@ -
+
-
+