diff --git a/config_app/config_endpoints/api/__init__.py b/config_app/config_endpoints/api/__init__.py index d9425670e..4f5b4c2f8 100644 --- a/config_app/config_endpoints/api/__init__.py +++ b/config_app/config_endpoints/api/__init__.py @@ -150,11 +150,11 @@ def kubernetes_only(f): nickname = partial(add_method_metadata, 'nickname') -import config_endpoints.api -import config_endpoints.api.discovery -import config_endpoints.api.kubeconfig -import config_endpoints.api.suconfig -import config_endpoints.api.superuser -import config_endpoints.api.tar_config_loader -import config_endpoints.api.user +import config_app.config_endpoints.api +import config_app.config_endpoints.api.discovery +import config_app.config_endpoints.api.kubeconfig +import config_app.config_endpoints.api.suconfig +import config_app.config_endpoints.api.superuser +import config_app.config_endpoints.api.tar_config_loader +import config_app.config_endpoints.api.user diff --git a/config_app/config_endpoints/api/suconfig.py b/config_app/config_endpoints/api/suconfig.py index 01532c47d..fdf58ee3b 100644 --- a/config_app/config_endpoints/api/suconfig.py +++ b/config_app/config_endpoints/api/suconfig.py @@ -74,6 +74,11 @@ class SuperUserConfig(ApiResource): # Write the configuration changes to the config override file. config_provider.save_config(config_object) + # now try to connect to the db provided in their config to validate it works + combined = dict(**app.config) + combined.update(config_provider.get_config()) + configure(combined, testing=app.config['TESTING']) + return { 'exists': True, 'config': config_object diff --git a/config_app/config_endpoints/api/superuser.py b/config_app/config_endpoints/api/superuser.py index a3f1039b3..8874b10d3 100644 --- a/config_app/config_endpoints/api/superuser.py +++ b/config_app/config_endpoints/api/superuser.py @@ -54,9 +54,10 @@ class SuperUserCustomCertificate(ApiResource): return '', 204 # Call the update script with config dir location to install the certificate immediately. - cert_dir = os.path.join(config_provider.get_config_dir_path(), EXTRA_CA_DIRECTORY) - if subprocess.call([os.path.join(INIT_SCRIPTS_LOCATION, 'certs_install.sh')], env={ 'CERTDIR': cert_dir }) != 0: - raise Exception('Could not install certificates') + if not app.config['TESTING']: + cert_dir = os.path.join(config_provider.get_config_dir_path(), EXTRA_CA_DIRECTORY) + if subprocess.call([os.path.join(INIT_SCRIPTS_LOCATION, 'certs_install.sh')], env={ 'CERTDIR': cert_dir }) != 0: + raise Exception('Could not install certificates') return '', 204 diff --git a/config_app/config_test/__init__.py b/config_app/config_test/__init__.py new file mode 100644 index 000000000..bb3463e2d --- /dev/null +++ b/config_app/config_test/__init__.py @@ -0,0 +1,149 @@ +import json as py_json +import unittest +from contextlib import contextmanager +from urllib import urlencode +from urlparse import urlparse, parse_qs, urlunparse + +from config_app.c_app import app, config_provider +from config_app.config_endpoints.api import api +from initdb import setup_database_for_testing, finished_database_for_testing + + +CSRF_TOKEN_KEY = '_csrf_token' +CSRF_TOKEN = '123csrfforme' + +READ_ACCESS_USER = 'reader' +ADMIN_ACCESS_USER = 'devtable' +ADMIN_ACCESS_EMAIL = 'jschorr@devtable.com' + +# OVERRIDES FROM PORTING FROM OLD APP: +all_queues = [] # the config app doesn't have any queues + +class ApiTestCase(unittest.TestCase): + maxDiff = None + + @staticmethod + def _add_csrf(without_csrf): + parts = urlparse(without_csrf) + query = parse_qs(parts[4]) + query[CSRF_TOKEN_KEY] = CSRF_TOKEN + return urlunparse(list(parts[0:4]) + [urlencode(query)] + list(parts[5:])) + + def url_for(self, resource_name, params=None, skip_csrf=False): + params = params or {} + url = api.url_for(resource_name, **params) + if not skip_csrf: + url = ApiTestCase._add_csrf(url) + return url + + def setUp(self): + setup_database_for_testing(self) + self.app = app.test_client() + self.ctx = app.test_request_context() + self.ctx.__enter__() + self.setCsrfToken(CSRF_TOKEN) + + def tearDown(self): + finished_database_for_testing(self) + config_provider.clear() + self.ctx.__exit__(True, None, None) + + def setCsrfToken(self, token): + with self.app.session_transaction() as sess: + sess[CSRF_TOKEN_KEY] = token + + @contextmanager + def toggleFeature(self, name, enabled): + import features + previous_value = getattr(features, name) + setattr(features, name, enabled) + yield + setattr(features, name, previous_value) + + def getJsonResponse(self, resource_name, params={}, expected_code=200): + rv = self.app.get(api.url_for(resource_name, **params)) + self.assertEquals(expected_code, rv.status_code) + data = rv.data + parsed = py_json.loads(data) + return parsed + + def postResponse(self, resource_name, params={}, data={}, file=None, headers=None, + expected_code=200): + data = py_json.dumps(data) + + headers = headers or {} + headers.update({"Content-Type": "application/json"}) + + if file is not None: + data = {'file': file} + headers = None + + rv = self.app.post(self.url_for(resource_name, params), data=data, headers=headers) + self.assertEquals(rv.status_code, expected_code) + return rv.data + + def getResponse(self, resource_name, params={}, expected_code=200): + rv = self.app.get(api.url_for(resource_name, **params)) + self.assertEquals(rv.status_code, expected_code) + return rv.data + + def putResponse(self, resource_name, params={}, data={}, expected_code=200): + rv = self.app.put( + self.url_for(resource_name, params), data=py_json.dumps(data), + headers={"Content-Type": "application/json"}) + self.assertEquals(rv.status_code, expected_code) + return rv.data + + def deleteResponse(self, resource_name, params={}, expected_code=204): + rv = self.app.delete(self.url_for(resource_name, params)) + + if rv.status_code != expected_code: + print 'Mismatch data for resource DELETE %s: %s' % (resource_name, rv.data) + + self.assertEquals(rv.status_code, expected_code) + return rv.data + + def deleteEmptyResponse(self, resource_name, params={}, expected_code=204): + rv = self.app.delete(self.url_for(resource_name, params)) + self.assertEquals(rv.status_code, expected_code) + self.assertEquals(rv.data, '') # ensure response body empty + return + + def postJsonResponse(self, resource_name, params={}, data={}, expected_code=200): + rv = self.app.post( + self.url_for(resource_name, params), data=py_json.dumps(data), + headers={"Content-Type": "application/json"}) + + if rv.status_code != expected_code: + print 'Mismatch data for resource POST %s: %s' % (resource_name, rv.data) + + self.assertEquals(rv.status_code, expected_code) + data = rv.data + parsed = py_json.loads(data) + return parsed + + def putJsonResponse(self, resource_name, params={}, data={}, expected_code=200, skip_csrf=False): + rv = self.app.put( + self.url_for(resource_name, params, skip_csrf), data=py_json.dumps(data), + headers={"Content-Type": "application/json"}) + + if rv.status_code != expected_code: + print 'Mismatch data for resource PUT %s: %s' % (resource_name, rv.data) + + self.assertEquals(rv.status_code, expected_code) + data = rv.data + parsed = py_json.loads(data) + return parsed + + def assertNotInTeam(self, data, membername): + for memberData in data['members']: + if memberData['name'] == membername: + self.fail(membername + ' found in team: ' + data['name']) + + def assertInTeam(self, data, membername): + for member_data in data['members']: + if member_data['name'] == membername: + return + + self.fail(membername + ' not found in team: ' + data['name']) + diff --git a/config_app/config_test/test_api_usage.py b/config_app/config_test/test_api_usage.py new file mode 100644 index 000000000..cb4b2f1d1 --- /dev/null +++ b/config_app/config_test/test_api_usage.py @@ -0,0 +1,208 @@ +from StringIO import StringIO +from mockldap import MockLdap + +from data import database, model +from util.security.test.test_ssl_util import generate_test_cert + +from config_app.c_app import app +from config_app.config_test import ApiTestCase, all_queues, ADMIN_ACCESS_USER, ADMIN_ACCESS_EMAIL +from config_app.config_endpoints.api import api_bp +from config_app.config_endpoints.api.superuser import SuperUserCustomCertificate, SuperUserCustomCertificates +from config_app.config_endpoints.api.suconfig import SuperUserConfig, SuperUserCreateInitialSuperUser, \ + SuperUserConfigFile, SuperUserRegistryStatus + +try: + app.register_blueprint(api_bp, url_prefix='/api') +except ValueError: + # This blueprint was already registered + pass + + +class TestSuperUserCreateInitialSuperUser(ApiTestCase): + def test_create_superuser(self): + data = { + 'username': 'newsuper', + 'password': 'password', + 'email': 'jschorr+fake@devtable.com', + } + + # Add some fake config. + fake_config = { + 'AUTHENTICATION_TYPE': 'Database', + 'SECRET_KEY': 'fakekey', + } + + self.putJsonResponse(SuperUserConfig, data=dict(config=fake_config, hostname='fakehost')) + + # Try to write with config. Should 403 since there are users in the DB. + self.postResponse(SuperUserCreateInitialSuperUser, data=data, expected_code=403) + + # Delete all users in the DB. + for user in list(database.User.select()): + model.user.delete_user(user, all_queues) + + # Create the superuser. + self.postJsonResponse(SuperUserCreateInitialSuperUser, data=data) + + # Ensure the user exists in the DB. + self.assertIsNotNone(model.user.get_user('newsuper')) + + # Ensure that the current user is a superuser in the config. + json = self.getJsonResponse(SuperUserConfig) + self.assertEquals(['newsuper'], json['config']['SUPER_USERS']) + + # Ensure that the current user is a superuser in memory by trying to call an API + # that will fail otherwise. + self.getResponse(SuperUserConfigFile, params=dict(filename='ssl.cert')) + + +class TestSuperUserConfig(ApiTestCase): + def test_get_status_update_config(self): + # With no config the status should be 'config-db'. + json = self.getJsonResponse(SuperUserRegistryStatus) + self.assertEquals('config-db', json['status']) + + # Add some fake config. + fake_config = { + 'AUTHENTICATION_TYPE': 'Database', + 'SECRET_KEY': 'fakekey', + } + + json = self.putJsonResponse(SuperUserConfig, data=dict(config=fake_config, + hostname='fakehost')) + self.assertEquals('fakekey', json['config']['SECRET_KEY']) + self.assertEquals('fakehost', json['config']['SERVER_HOSTNAME']) + self.assertEquals('Database', json['config']['AUTHENTICATION_TYPE']) + + # With config the status should be 'setup-db'. + # TODO(sam): fix this test + # json = self.getJsonResponse(SuperUserRegistryStatus) + # self.assertEquals('setup-db', json['status']) + + def test_config_file(self): + # Try for an invalid file. Should 404. + self.getResponse(SuperUserConfigFile, params=dict(filename='foobar'), expected_code=404) + + # Try for a valid filename. Should not exist. + json = self.getJsonResponse(SuperUserConfigFile, params=dict(filename='ssl.cert')) + self.assertFalse(json['exists']) + + # Add the file. + self.postResponse(SuperUserConfigFile, params=dict(filename='ssl.cert'), + file=(StringIO('my file contents'), 'ssl.cert')) + + # Should now exist. + json = self.getJsonResponse(SuperUserConfigFile, params=dict(filename='ssl.cert')) + self.assertTrue(json['exists']) + + def test_update_with_external_auth(self): + # Run a mock LDAP. + mockldap = MockLdap({ + 'dc=quay,dc=io': { + 'dc': ['quay', 'io'] + }, + 'ou=employees,dc=quay,dc=io': { + 'dc': ['quay', 'io'], + 'ou': 'employees' + }, + 'uid=' + ADMIN_ACCESS_USER + ',ou=employees,dc=quay,dc=io': { + 'dc': ['quay', 'io'], + 'ou': 'employees', + 'uid': [ADMIN_ACCESS_USER], + 'userPassword': ['password'], + 'mail': [ADMIN_ACCESS_EMAIL], + }, + }) + + config = { + 'AUTHENTICATION_TYPE': 'LDAP', + 'LDAP_BASE_DN': ['dc=quay', 'dc=io'], + 'LDAP_ADMIN_DN': 'uid=devtable,ou=employees,dc=quay,dc=io', + 'LDAP_ADMIN_PASSWD': 'password', + 'LDAP_USER_RDN': ['ou=employees'], + 'LDAP_UID_ATTR': 'uid', + 'LDAP_EMAIL_ATTR': 'mail', + } + + mockldap.start() + try: + # Write the config with the valid password. + self.putResponse(SuperUserConfig, + data={'config': config, + 'password': 'password', + 'hostname': 'foo'}, expected_code=200) + + # Ensure that the user row has been linked. + # TODO(sam): fix this test + # self.assertEquals(ADMIN_ACCESS_USER, + # model.user.verify_federated_login('ldap', ADMIN_ACCESS_USER).username) + finally: + mockldap.stop() + +class TestSuperUserCustomCertificates(ApiTestCase): + def test_custom_certificates(self): + + # Upload a certificate. + cert_contents, _ = generate_test_cert(hostname='somecoolhost', san_list=['DNS:bar', 'DNS:baz']) + self.postResponse(SuperUserCustomCertificate, params=dict(certpath='testcert.crt'), + file=(StringIO(cert_contents), 'testcert.crt'), expected_code=204) + + # Make sure it is present. + json = self.getJsonResponse(SuperUserCustomCertificates) + self.assertEquals(1, len(json['certs'])) + + cert_info = json['certs'][0] + self.assertEquals('testcert.crt', cert_info['path']) + + self.assertEquals(set(['somecoolhost', 'bar', 'baz']), set(cert_info['names'])) + self.assertFalse(cert_info['expired']) + + # Remove the certificate. + self.deleteResponse(SuperUserCustomCertificate, params=dict(certpath='testcert.crt')) + + # Make sure it is gone. + json = self.getJsonResponse(SuperUserCustomCertificates) + self.assertEquals(0, len(json['certs'])) + + def test_expired_custom_certificate(self): + # Upload a certificate. + cert_contents, _ = generate_test_cert(hostname='somecoolhost', expires=-10) + self.postResponse(SuperUserCustomCertificate, params=dict(certpath='testcert.crt'), + file=(StringIO(cert_contents), 'testcert.crt'), expected_code=204) + + # Make sure it is present. + json = self.getJsonResponse(SuperUserCustomCertificates) + self.assertEquals(1, len(json['certs'])) + + cert_info = json['certs'][0] + self.assertEquals('testcert.crt', cert_info['path']) + + self.assertEquals(set(['somecoolhost']), set(cert_info['names'])) + self.assertTrue(cert_info['expired']) + + def test_invalid_custom_certificate(self): + # Upload an invalid certificate. + self.postResponse(SuperUserCustomCertificate, params=dict(certpath='testcert.crt'), + file=(StringIO('some contents'), 'testcert.crt'), expected_code=204) + + # Make sure it is present but invalid. + json = self.getJsonResponse(SuperUserCustomCertificates) + self.assertEquals(1, len(json['certs'])) + + cert_info = json['certs'][0] + self.assertEquals('testcert.crt', cert_info['path']) + self.assertEquals('no start line', cert_info['error']) + + def test_path_sanitization(self): + # Upload a certificate. + cert_contents, _ = generate_test_cert(hostname='somecoolhost', expires=-10) + self.postResponse(SuperUserCustomCertificate, params=dict(certpath='testcert/../foobar.crt'), + file=(StringIO(cert_contents), 'testcert/../foobar.crt'), expected_code=204) + + # Make sure it is present. + json = self.getJsonResponse(SuperUserCustomCertificates) + self.assertEquals(1, len(json['certs'])) + + cert_info = json['certs'][0] + self.assertEquals('foobar.crt', cert_info['path']) + diff --git a/config_app/config_test/test_suconfig_api.py b/config_app/config_test/test_suconfig_api.py new file mode 100644 index 000000000..af85fb4d8 --- /dev/null +++ b/config_app/config_test/test_suconfig_api.py @@ -0,0 +1,149 @@ +import unittest + +from data.database import User +from data import model + +from config_app.config_endpoints.api.suconfig import SuperUserConfig, SuperUserConfigValidate, SuperUserConfigFile, \ + SuperUserRegistryStatus, SuperUserCreateInitialSuperUser +from config_app.config_endpoints.api import api_bp +from config_app.config_test import ApiTestCase, READ_ACCESS_USER, ADMIN_ACCESS_USER +from config_app.c_app import app, config_provider + +try: + app.register_blueprint(api_bp, url_prefix='/api') +except ValueError: + # This blueprint was already registered + pass + +# OVERRIDES FROM PORTING FROM OLD APP: +all_queues = [] # the config app doesn't have any queues + +class FreshConfigProvider(object): + def __enter__(self): + config_provider.reset_for_test() + return config_provider + + def __exit__(self, type, value, traceback): + config_provider.reset_for_test() + + +class TestSuperUserRegistryStatus(ApiTestCase): + def test_registry_status(self): + with FreshConfigProvider(): + json = self.getJsonResponse(SuperUserRegistryStatus) + self.assertEquals('config-db', json['status']) + + +class TestSuperUserConfigFile(ApiTestCase): + def test_get_superuser_invalid_filename(self): + with FreshConfigProvider(): + self.getResponse(SuperUserConfigFile, params=dict(filename='somefile'), expected_code=404) + + def test_get_superuser(self): + with FreshConfigProvider(): + result = self.getJsonResponse(SuperUserConfigFile, params=dict(filename='ssl.cert')) + self.assertFalse(result['exists']) + + def test_post_no_file(self): + with FreshConfigProvider(): + # No file + self.postResponse(SuperUserConfigFile, params=dict(filename='ssl.cert'), expected_code=400) + + def test_post_superuser_invalid_filename(self): + with FreshConfigProvider(): + self.postResponse(SuperUserConfigFile, params=dict(filename='somefile'), expected_code=404) + + def test_post_superuser(self): + with FreshConfigProvider(): + self.postResponse(SuperUserConfigFile, params=dict(filename='ssl.cert'), expected_code=400) + + +class TestSuperUserCreateInitialSuperUser(ApiTestCase): + def test_no_config_file(self): + with FreshConfigProvider(): + # If there is no config.yaml, then this method should security fail. + data = dict(username='cooluser', password='password', email='fake@example.com') + self.postResponse(SuperUserCreateInitialSuperUser, data=data, expected_code=403) + + def test_config_file_with_db_users(self): + with FreshConfigProvider(): + # Write some config. + self.putJsonResponse(SuperUserConfig, data=dict(config={}, hostname='foobar')) + + # If there is a config.yaml, but existing DB users exist, then this method should security + # fail. + data = dict(username='cooluser', password='password', email='fake@example.com') + self.postResponse(SuperUserCreateInitialSuperUser, data=data, expected_code=403) + + def test_config_file_with_no_db_users(self): + with FreshConfigProvider(): + # Write some config. + self.putJsonResponse(SuperUserConfig, data=dict(config={}, hostname='foobar')) + + # Delete all the users in the DB. + for user in list(User.select()): + model.user.delete_user(user, all_queues) + + # This method should now succeed. + data = dict(username='cooluser', password='password', email='fake@example.com') + result = self.postJsonResponse(SuperUserCreateInitialSuperUser, data=data) + self.assertTrue(result['status']) + + # Verify the superuser was created. + User.get(User.username == 'cooluser') + + # Verify the superuser was placed into the config. + result = self.getJsonResponse(SuperUserConfig) + self.assertEquals(['cooluser'], result['config']['SUPER_USERS']) + + +class TestSuperUserConfigValidate(ApiTestCase): + def test_nonsuperuser_noconfig(self): + with FreshConfigProvider(): + result = self.postJsonResponse(SuperUserConfigValidate, params=dict(service='someservice'), + data=dict(config={})) + + self.assertFalse(result['status']) + + + def test_nonsuperuser_config(self): + with FreshConfigProvider(): + # The validate config call works if there is no config.yaml OR the user is a superuser. + # Add a config, and verify it breaks when unauthenticated. + json = self.putJsonResponse(SuperUserConfig, data=dict(config={}, hostname='foobar')) + self.assertTrue(json['exists']) + + + result = self.postJsonResponse(SuperUserConfigValidate, params=dict(service='someservice'), + data=dict(config={})) + + self.assertFalse(result['status']) + + +class TestSuperUserConfig(ApiTestCase): + def test_get_superuser(self): + with FreshConfigProvider(): + json = self.getJsonResponse(SuperUserConfig) + + # Note: We expect the config to be none because a config.yaml should never be checked into + # the directory. + self.assertIsNone(json['config']) + + def test_put(self): + with FreshConfigProvider() as config: + json = self.putJsonResponse(SuperUserConfig, data=dict(config={}, hostname='foobar')) + self.assertTrue(json['exists']) + + # Verify the config file exists. + self.assertTrue(config.config_exists()) + + # This should succeed. + json = self.putJsonResponse(SuperUserConfig, data=dict(config={}, hostname='barbaz')) + self.assertTrue(json['exists']) + + json = self.getJsonResponse(SuperUserConfig) + self.assertIsNotNone(json['config']) + + +if __name__ == '__main__': + unittest.main() diff --git a/config_app/config_util/config/testprovider.py b/config_app/config_util/config/testprovider.py index 7b1890c5b..63e563056 100644 --- a/config_app/config_util/config/testprovider.py +++ b/config_app/config_util/config/testprovider.py @@ -78,3 +78,6 @@ class TestConfigProvider(BaseProvider): def get_volume_path(self, directory, filename): return os.path.join(directory, filename) + + def get_config_dir_path(self): + return '' diff --git a/data/database.py b/data/database.py index 6eb320400..506560469 100644 --- a/data/database.py +++ b/data/database.py @@ -321,7 +321,7 @@ def _db_from_url(url, db_kwargs, connect_timeout=DEFAULT_DB_CONNECT_TIMEOUT, return driver(parsed_url.database, **db_kwargs) -def configure(config_object): +def configure(config_object, testing=False): logger.debug('Configuring database') db_kwargs = dict(config_object['DB_CONNECTION_ARGS']) write_db_uri = config_object['DB_URI'] @@ -344,7 +344,7 @@ def configure(config_object): @contextmanager def _ensure_under_transaction(): - if not config_object['TESTING']: + if not testing and not config_object['TESTING']: if db.transaction_depth() == 0: raise Exception('Expected to be under a transaction') diff --git a/endpoints/api/suconfig.py b/endpoints/api/suconfig.py index bceeb3e43..7eb4ba415 100644 --- a/endpoints/api/suconfig.py +++ b/endpoints/api/suconfig.py @@ -7,21 +7,10 @@ import subprocess from flask import abort -from app import (app, config_provider, superusers, OVERRIDE_CONFIG_DIRECTORY, ip_resolver, - instance_keys, INIT_SCRIPTS_LOCATION) +from app import app, config_provider from auth.permissions import SuperUserPermission -from auth.auth_context import get_authenticated_user -from data.database import configure -from data.runmigration import run_alembic_migration -from data.users import get_federated_service_name, get_users_handler from endpoints.api.suconfig_models_pre_oci import pre_oci_model as model -from endpoints.api import (ApiResource, nickname, resource, internal_only, show_if, - require_fresh_login, request, validate_json_request, verify_not_prod) -from endpoints.common import common_login -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, is_valid_config_upload_filename, - ValidatorContext) +from endpoints.api import (ApiResource, nickname, resource, internal_only, show_if, verify_not_prod) import features @@ -96,41 +85,6 @@ class _AlembicLogHandler(logging.Handler): 'message': record.getMessage() }) -@resource('/v1/superuser/setupdb') -@internal_only -@show_if(features.SUPER_USERS) -class SuperUserSetupDatabase(ApiResource): - """ Resource for invoking alembic to setup the database. """ - @verify_not_prod - @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(app.config['DB_URI'], log_handler) - except Exception as ex: - return { - 'error': str(ex) - } - - return { - 'logs': log_handler.records - } - - abort(403) - - # From: https://stackoverflow.com/a/44712205 def get_process_id(name): """Return process ids found by (partial) name or regex. @@ -168,251 +122,3 @@ class SuperUserShutdown(ApiResource): return {} abort(403) - - -@resource('/v1/superuser/config') -@internal_only -@show_if(features.SUPER_USERS) -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' - }, - }, - }, - } - - @require_fresh_login - @verify_not_prod - @nickname('scGetConfig') - def get(self): - """ Returns the currently defined configuration, if any. """ - if SuperUserPermission().can(): - config_object = config_provider.get_config() - return { - 'config': config_object - } - - abort(403) - - @nickname('scUpdateConfig') - @verify_not_prod - @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() or SuperUserPermission().can(): - 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/config/file/') -@internal_only -@show_if(features.SUPER_USERS) -class SuperUserConfigFile(ApiResource): - """ Resource for fetching the status of config files and overriding them. """ - @nickname('scConfigFileExists') - @verify_not_prod - def get(self, filename): - """ Returns whether the configuration file with the given name exists. """ - if not is_valid_config_upload_filename(filename): - abort(404) - - if SuperUserPermission().can(): - return { - 'exists': config_provider.volume_file_exists(filename) - } - - abort(403) - - @nickname('scUpdateConfigFile') - @verify_not_prod - 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. - if not config_provider.config_exists() or SuperUserPermission().can(): - uploaded_file = request.files['file'] - if not uploaded_file: - abort(400) - - config_provider.save_volume_file(uploaded_file, filename) - return { - 'status': True - } - - abort(403) - - -@resource('/v1/superuser/config/createsuperuser') -@internal_only -@show_if(features.SUPER_USERS) -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') - @verify_not_prod - @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. - superusers.register_superuser(username) - - # Conduct login with that user. - common_login(superuser_uuid) - - return { - 'status': True - } - - - abort(403) - - -@resource('/v1/superuser/config/validate/') -@internal_only -@show_if(features.SUPER_USERS) -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') - @verify_not_prod - @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() or SuperUserPermission().can(): - 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) - - abort(403) diff --git a/endpoints/api/superuser.py b/endpoints/api/superuser.py index 1efb49547..99a4fbc45 100644 --- a/endpoints/api/superuser.py +++ b/endpoints/api/superuser.py @@ -7,13 +7,11 @@ import socket from datetime import datetime, timedelta from random import SystemRandom -import pathvalidate - from flask import request, make_response, jsonify import features -from app import app, avatar, superusers, authentication, config_provider, INIT_SCRIPTS_LOCATION +from app import app, avatar, superusers, authentication, config_provider from auth import scopes from auth.auth_context import get_authenticated_user from auth.permissions import SuperUserPermission @@ -29,8 +27,6 @@ from endpoints.api.superuser_models_pre_oci import (pre_oci_model, ServiceKeyDoe InvalidRepositoryBuildException) from endpoints.api.logs_models_pre_oci import pre_oci_model as log_model from util.useremails import send_confirmation_email, send_recovery_email -from util.security.ssl import load_certificate, CertInvalidException -from util.config.validator import EXTRA_CA_DIRECTORY from _init import ROOT_DIR logger = logging.getLogger(__name__) @@ -860,118 +856,6 @@ class SuperUserServiceKeyApproval(ApiResource): raise Unauthorized() -@resource('/v1/superuser/customcerts') -@internal_only -@show_if(features.SUPER_USERS) -class SuperUserCustomCertificates(ApiResource): - """ Resource for managing custom certificates. """ - - @nickname('getCustomCertificates') - @require_fresh_login - @require_scope(scopes.SUPERUSER) - @verify_not_prod - def get(self): - if SuperUserPermission().can(): - 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, - } - - raise Unauthorized() - - -@resource('/v1/superuser/customcerts/') -@internal_only -@show_if(features.SUPER_USERS) -class SuperUserCustomCertificate(ApiResource): - """ Resource for managing a custom certificate. """ - - @nickname('uploadCustomCertificate') - @require_fresh_login - @require_scope(scopes.SUPERUSER) - @verify_not_prod - def post(self, certpath): - if SuperUserPermission().can(): - 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(uploaded_file, cert_full_path) - 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(os.path.join(INIT_SCRIPTS_LOCATION, 'certs_install.sh')) != 0: - raise Exception('Could not install certificates') - - logger.debug('certs_install.sh completed') - - return '', 204 - - raise Unauthorized() - - @nickname('deleteCustomCertificate') - @require_fresh_login - @require_scope(scopes.SUPERUSER) - @verify_not_prod - def delete(self, certpath): - if SuperUserPermission().can(): - cert_full_path = config_provider.get_volume_path(EXTRA_CA_DIRECTORY, certpath) - config_provider.remove_volume_file(cert_full_path) - return '', 204 - - raise Unauthorized() - - @resource('/v1/superuser//logs') @path_param('build_uuid', 'The UUID of the build') @show_if(features.SUPER_USERS) diff --git a/endpoints/api/test/test_security.py b/endpoints/api/test/test_security.py index ef3fabbd9..2c95c184d 100644 --- a/endpoints/api/test/test_security.py +++ b/endpoints/api/test/test_security.py @@ -1267,11 +1267,6 @@ SECURITY_TESTS = [ (SuperUserList, 'POST', None, {'username': 'foo'}, 'freshuser', 403), (SuperUserList, 'POST', None, {'username': 'foo'}, 'reader', 403), - (SuperUserCustomCertificates, 'GET', None, None, None, 401), - (SuperUserCustomCertificates, 'GET', None, None, 'devtable', 200), - (SuperUserCustomCertificates, 'GET', None, None, 'freshuser', 403), - (SuperUserCustomCertificates, 'GET', None, None, 'reader', 403), - (SuperUserSystemLogServices, 'GET', None, None, None, 401), (SuperUserSystemLogServices, 'GET', None, None, 'devtable', 200), (SuperUserSystemLogServices, 'GET', None, None, 'freshuser', 403), @@ -1282,15 +1277,6 @@ SECURITY_TESTS = [ (SuperUserGetLogsForService, 'GET', {'service': 'foo'}, None, 'freshuser', 403), (SuperUserGetLogsForService, 'GET', {'service': 'foo'}, None, 'reader', 403), - (SuperUserCustomCertificate, 'DELETE', {'certpath': 'somecert.crt'}, None, None, 401), - (SuperUserCustomCertificate, 'DELETE', {'certpath': 'somecert.crt'}, None, 'devtable', 204), - (SuperUserCustomCertificate, 'DELETE', {'certpath': 'somecert.crt'}, None, 'freshuser', 403), - (SuperUserCustomCertificate, 'DELETE', {'certpath': 'somecert.crt'}, None, 'reader', 403), - (SuperUserCustomCertificate, 'POST', {'certpath': 'somecert.crt'}, None, None, 401), - (SuperUserCustomCertificate, 'POST', {'certpath': 'somecert.crt'}, None, 'devtable', 400), - (SuperUserCustomCertificate, 'POST', {'certpath': 'somecert.crt'}, None, 'freshuser', 403), - (SuperUserCustomCertificate, 'POST', {'certpath': 'somecert.crt'}, None, 'reader', 403), - (SuperUserManagement, 'DELETE', {'username': 'freshuser'}, None, None, 401), (SuperUserManagement, 'DELETE', {'username': 'freshuser'}, None, 'devtable', 204), (SuperUserManagement, 'DELETE', {'username': 'freshuser'}, None, 'freshuser', 403), diff --git a/endpoints/common.py b/endpoints/common.py index 38febd4ee..c00783023 100644 --- a/endpoints/common.py +++ b/endpoints/common.py @@ -9,12 +9,13 @@ from flask_principal import identity_changed import endpoints.decorated # Register the various exceptions via decorators. import features -from app import app, oauth_apps, oauth_login, LoginWrappedDBUser, user_analytics +from app import app, oauth_apps, oauth_login, LoginWrappedDBUser, user_analytics, IS_KUBERNETES from auth import scopes from auth.permissions import QuayDeferredPermissionUser from config import frontend_visible_config from external_libraries import get_external_javascript, get_external_css from endpoints.common_models_pre_oci import pre_oci_model as model +from util.config.provider.k8sprovider import QE_NAMESPACE from util.secscan import PRIORITY_LEVELS from util.saas.useranalytics import build_error_callback from util.timedeltastring import convert_to_timedelta @@ -143,6 +144,7 @@ def render_page_template(name, route_data=None, **kwargs): preferred_scheme=app.config['PREFERRED_URL_SCHEME'], version_number=version_number, current_year=datetime.datetime.now().year, + kubernetes_namespace=IS_KUBERNETES and QE_NAMESPACE, **kwargs) resp = make_response(contents) diff --git a/static/directives/config/config-bool-field.html b/static/directives/config/config-bool-field.html deleted file mode 100644 index 190698290..000000000 --- a/static/directives/config/config-bool-field.html +++ /dev/null @@ -1,8 +0,0 @@ -
-
- -
-
diff --git a/static/directives/config/config-certificates-field.html b/static/directives/config/config-certificates-field.html deleted file mode 100644 index f20e4c459..000000000 --- a/static/directives/config/config-certificates-field.html +++ /dev/null @@ -1,76 +0,0 @@ -
-
- -
- extra_ca_certs is a single file and cannot be processed by this tool. If a valid and appended list of certificates, they will be installed on container startup. -
- -
-
-

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

-

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

-

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

-

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

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

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

-

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

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

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

-

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

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

Callback URLs for this service:

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

Various settings around access and authentication to the registry.

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

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

-

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

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

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

-

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

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

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

-

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

-

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

-
- -
- Enable GitLab Triggers -
- - - - - - - - - - - - - - - - - - -
GitLab: - -
GitLab Endpoint: - - -
- The GitLab Enterprise endpoint. Must start with http:// or https://. -
-
Application Id: - - -
Secret: - - -
-
-
- - - - -
- - -
- - - - -
-
diff --git a/static/directives/config/config-string-field.html b/static/directives/config/config-string-field.html deleted file mode 100644 index 703891f89..000000000 --- a/static/directives/config/config-string-field.html +++ /dev/null @@ -1,10 +0,0 @@ -
-
- -
- {{ errorMessage }} -
-
-
diff --git a/static/directives/config/config-string-list-field.html b/static/directives/config/config-string-list-field.html deleted file mode 100644 index de29dfb91..000000000 --- a/static/directives/config/config-string-list-field.html +++ /dev/null @@ -1,6 +0,0 @@ -
-
- -
-
diff --git a/static/directives/config/config-variable-field.html b/static/directives/config/config-variable-field.html deleted file mode 100644 index 9236469cd..000000000 --- a/static/directives/config/config-variable-field.html +++ /dev/null @@ -1,10 +0,0 @@ -
-
- -
- - -
diff --git a/static/directives/request-service-key-dialog.html b/static/directives/request-service-key-dialog.html deleted file mode 100644 index 27ba1ba13..000000000 --- a/static/directives/request-service-key-dialog.html +++ /dev/null @@ -1,137 +0,0 @@ -
- - -
-
\ No newline at end of file diff --git a/static/js/core-config-setup.js b/static/js/core-config-setup.js deleted file mode 100644 index e969ca1d5..000000000 --- a/static/js/core-config-setup.js +++ /dev/null @@ -1,1440 +0,0 @@ -import * as URI from 'urijs'; - -angular.module("core-config-setup", ['angularFileUpload']) - .directive('configSetupTool', function() { - var directiveDefinitionObject = { - priority: 1, - templateUrl: '/static/directives/config/config-setup-tool.html', - replace: true, - transclude: true, - restrict: 'C', - scope: { - 'isActive': '=isActive', - 'configurationSaved': '&configurationSaved' - }, - controller: function($rootScope, $scope, $element, $timeout, ApiService) { - var authPassword = null; - - $scope.HOSTNAME_REGEX = '^[a-zA-Z-0-9\.]+(:[0-9]+)?$'; - $scope.GITHOST_REGEX = '^https?://([a-zA-Z0-9]+\.?\/?)+$'; - - $scope.SERVICES = [ - {'id': 'redis', 'title': 'Redis'}, - - {'id': 'registry-storage', 'title': 'Registry Storage'}, - - {'id': 'time-machine', 'title': 'Time Machine'}, - - {'id': 'access', 'title': 'Access Settings'}, - - {'id': 'ssl', 'title': 'SSL certificate and key', 'condition': function(config) { - return config.PREFERRED_URL_SCHEME == 'https'; - }}, - - {'id': 'ldap', 'title': 'LDAP Authentication', 'condition': function(config) { - return config.AUTHENTICATION_TYPE == 'LDAP'; - }, 'password': true}, - - {'id': 'jwt', 'title': 'JWT Authentication', 'condition': function(config) { - return config.AUTHENTICATION_TYPE == 'JWT'; - }, 'password': true}, - - {'id': 'keystone', 'title': 'Keystone Authentication', 'condition': function(config) { - return config.AUTHENTICATION_TYPE == 'Keystone'; - }, 'password': true}, - - {'id': 'apptoken-auth', 'title': 'App Token Authentication', 'condition': function(config) { - return config.AUTHENTICATION_TYPE == 'AppToken'; - }}, - - {'id': 'signer', 'title': 'ACI Signing', 'condition': function(config) { - return config.FEATURE_ACI_CONVERSION; - }}, - - {'id': 'mail', 'title': 'E-mail Support', 'condition': function(config) { - return config.FEATURE_MAILING; - }}, - - {'id': 'github-login', 'title': 'Github (Enterprise) Authentication', 'condition': function(config) { - return config.FEATURE_GITHUB_LOGIN; - }}, - - {'id': 'google-login', 'title': 'Google Authentication', 'condition': function(config) { - return config.FEATURE_GOOGLE_LOGIN; - }}, - - {'id': 'github-trigger', 'title': 'GitHub (Enterprise) Build Triggers', 'condition': function(config) { - return config.FEATURE_GITHUB_BUILD; - }}, - - {'id': 'bitbucket-trigger', 'title': 'BitBucket Build Triggers', 'condition': function(config) { - return config.FEATURE_BITBUCKET_BUILD; - }}, - - {'id': 'gitlab-trigger', 'title': 'GitLab Build Triggers', 'condition': function(config) { - return config.FEATURE_GITLAB_BUILD; - }}, - - {'id': 'security-scanner', 'title': 'Quay Security Scanner', 'condition': function(config) { - return config.FEATURE_SECURITY_SCANNER; - }}, - - {'id': 'bittorrent', 'title': 'BitTorrent downloads', 'condition': function(config) { - return config.FEATURE_BITTORRENT; - }}, - - {'id': 'oidc-login', 'title': 'OIDC Login(s)', 'condition': function(config) { - return $scope.getOIDCProviders(config).length > 0; - }}, - - {'id': 'actionlogarchiving', 'title': 'Action Log Rotation', 'condition': function(config) { - return config.FEATURE_ACTION_LOG_ROTATION; - }}, - ]; - - $scope.STORAGE_CONFIG_FIELDS = { - 'LocalStorage': [ - {'name': 'storage_path', 'title': 'Storage Directory', 'placeholder': '/some/directory', 'kind': 'text'} - ], - - 'S3Storage': [ - {'name': 's3_bucket', 'title': 'S3 Bucket', 'placeholder': 'my-cool-bucket', 'kind': 'text'}, - {'name': 'storage_path', 'title': 'Storage Directory', 'placeholder': '/path/inside/bucket', 'kind': 'text'}, - {'name': 's3_access_key', 'title': 'AWS Access Key (optional if using IAM)', 'placeholder': 'accesskeyhere', 'kind': 'text', 'optional': true}, - {'name': 's3_secret_key', 'title': 'AWS Secret Key (optional if using IAM)', 'placeholder': 'secretkeyhere', 'kind': 'text', 'optional': true}, - {'name': 'host', 'title': 'S3 Host (optional)', 'placeholder': 's3.amazonaws.com', 'kind': 'text', 'optional': true}, - {'name': 'port', 'title': 'S3 Port (optional)', 'placeholder': '443', 'kind': 'text', 'pattern': '^[0-9]+$', 'optional': true} - ], - - 'AzureStorage': [ - {'name': 'azure_container', 'title': 'Azure Storage Container', 'placeholder': 'container', 'kind': 'text'}, - {'name': 'storage_path', 'title': 'Storage Directory', 'placeholder': '/path/inside/container', 'kind': 'text'}, - {'name': 'azure_account_name', 'title': 'Azure Account Name', 'placeholder': 'accountnamehere', 'kind': 'text'}, - {'name': 'azure_account_key', 'title': 'Azure Account Key', 'placeholder': 'accountkeyhere', 'kind': 'text', 'optional': true}, - {'name': 'sas_token', 'title': 'Azure SAS Token', 'placeholder': 'sastokenhere', 'kind': 'text', 'optional': true}, - ], - - 'GoogleCloudStorage': [ - {'name': 'access_key', 'title': 'Cloud Access Key', 'placeholder': 'accesskeyhere', 'kind': 'text'}, - {'name': 'secret_key', 'title': 'Cloud Secret Key', 'placeholder': 'secretkeyhere', 'kind': 'text'}, - {'name': 'bucket_name', 'title': 'GCS Bucket', 'placeholder': 'my-cool-bucket', 'kind': 'text'}, - {'name': 'storage_path', 'title': 'Storage Directory', 'placeholder': '/path/inside/bucket', 'kind': 'text'} - ], - - 'RadosGWStorage': [ - {'name': 'hostname', 'title': 'Rados Server Hostname', 'placeholder': 'my.rados.hostname', 'kind': 'text'}, - {'name': 'port', 'title': 'Custom Port (optional)', 'placeholder': '443', 'kind': 'text', 'pattern': '^[0-9]+$', 'optional': true}, - {'name': 'is_secure', 'title': 'Is Secure', 'placeholder': 'Require SSL', 'kind': 'bool'}, - {'name': 'access_key', 'title': 'Access Key', 'placeholder': 'accesskeyhere', 'kind': 'text', 'help_url': 'http://ceph.com/docs/master/radosgw/admin/'}, - {'name': 'secret_key', 'title': 'Secret Key', 'placeholder': 'secretkeyhere', 'kind': 'text'}, - {'name': 'bucket_name', 'title': 'Bucket Name', 'placeholder': 'my-cool-bucket', 'kind': 'text'}, - {'name': 'storage_path', 'title': 'Storage Directory', 'placeholder': '/path/inside/bucket', 'kind': 'text'} - ], - - 'SwiftStorage': [ - {'name': 'auth_version', 'title': 'Swift Auth Version', 'kind': 'option', 'values': [1, 2, 3]}, - {'name': 'auth_url', 'title': 'Swift Auth URL', 'placeholder': 'http://swiftdomain/auth/v1.0', 'kind': 'text'}, - {'name': 'swift_container', 'title': 'Swift Container Name', 'placeholder': 'mycontainer', 'kind': 'text', - 'help_text': 'The swift container for all objects. Must already exist inside Swift.'}, - - {'name': 'storage_path', 'title': 'Storage Path', 'placeholder': '/path/inside/container', 'kind': 'text'}, - - {'name': 'swift_user', 'title': 'Username', 'placeholder': 'accesskeyhere', 'kind': 'text', - 'help_text': 'Note: For Swift V1, this is "username:password" (-U on the CLI).'}, - {'name': 'swift_password', 'title': 'Key/Password', 'placeholder': 'secretkeyhere', 'kind': 'text', - 'help_text': 'Note: For Swift V1, this is the API token (-K on the CLI).'}, - - {'name': 'ca_cert_path', 'title': 'CA Cert Filename', 'placeholder': 'conf/stack/swift.cert', 'kind': 'text', 'optional': true}, - - {'name': 'temp_url_key', 'title': 'Temp URL Key (optional)', 'placholder': 'key-here', 'kind': 'text', 'optional': true, - 'help_url': 'https://coreos.com/products/enterprise-registry/docs/latest/swift-temp-url.html', - 'help_text': 'If enabled, will allow for faster pulls directly from Swift.'}, - - {'name': 'os_options', 'title': 'OS Options', 'kind': 'map', - 'keys': ['tenant_id', 'auth_token', 'service_type', 'endpoint_type', 'tenant_name', 'object_storage_url', 'region_name', - 'project_id', 'project_name', 'project_domain_name', 'user_domain_name', 'user_domain_id']} - ], - - 'CloudFrontedS3Storage': [ - {'name': 's3_bucket', 'title': 'S3 Bucket', 'placeholder': 'my-cool-bucket', 'kind': 'text'}, - {'name': 'storage_path', 'title': 'Storage Directory', 'placeholder': '/path/inside/bucket', 'kind': 'text'}, - {'name': 's3_access_key', 'title': 'AWS Access Key (optional if using IAM)', 'placeholder': 'accesskeyhere', 'kind': 'text', 'optional': true}, - {'name': 's3_secret_key', 'title': 'AWS Secret Key (optional if using IAM)', 'placeholder': 'secretkeyhere', 'kind': 'text', 'optional': true}, - {'name': 'host', 'title': 'S3 Host (optional)', 'placeholder': 's3.amazonaws.com', 'kind': 'text', 'optional': true}, - {'name': 'port', 'title': 'S3 Port (optional)', 'placeholder': '443', 'kind': 'text', 'pattern': '^[0-9]+$', 'optional': true}, - - {'name': 'cloudfront_distribution_domain', 'title': 'CloudFront Distribution Domain Name', 'placeholder': 'somesubdomain.cloudfront.net', 'pattern': '^([0-9a-zA-Z]+\\.)+[0-9a-zA-Z]+$', 'kind': 'text'}, - {'name': 'cloudfront_key_id', 'title': 'CloudFront Key ID', 'placeholder': 'APKATHISISAKEYID', 'kind': 'text'}, - {'name': 'cloudfront_privatekey_filename', 'title': 'CloudFront Private Key', 'filesuffix': 'cloudfront-signing-key.pem', 'kind': 'file'}, - ], - }; - - $scope.enableFeature = function(config, feature) { - config[feature] = true; - }; - - $scope.validateHostname = function(hostname) { - if (hostname.indexOf('127.0.0.1') == 0 || hostname.indexOf('localhost') == 0) { - return 'Please specify a non-localhost hostname. "localhost" will refer to the container, not your machine.' - } - - return null; - }; - - $scope.config = null; - $scope.mapped = { - '$hasChanges': false - }; - - $scope.hasfile = {}; - $scope.validating = null; - $scope.savingConfiguration = false; - - $scope.removeOIDCProvider = function(provider) { - delete $scope.config[provider]; - }; - - $scope.addOIDCProvider = function() { - bootbox.prompt('Enter an ID for the OIDC provider', function(result) { - if (!result) { - return; - } - - result = result.toUpperCase(); - - if (!result.match(/^[A-Z0-9]+$/)) { - bootbox.alert('Invalid ID for OIDC provider: must be alphanumeric'); - return; - } - - if (result == 'GITHUB' || result == 'GOOGLE') { - bootbox.alert('Invalid ID for OIDC provider: cannot be a reserved name'); - return; - } - - var key = result + '_LOGIN_CONFIG'; - if ($scope.config[key]) { - bootbox.alert('Invalid ID for OIDC provider: already exists'); - return; - } - - $scope.config[key] = {}; - }); - }; - - $scope.getOIDCProviderId = function(key) { - var index = key.indexOf('_LOGIN_CONFIG'); - if (index <= 0) { - return null; - } - - return key.substr(0, index).toLowerCase(); - }; - - $scope.getOIDCProviders = function(config) { - var keys = Object.keys(config || {}); - return keys.filter(function(key) { - if (key == 'GITHUB_LOGIN_CONFIG' || key == 'GOOGLE_LOGIN_CONFIG') { - // Has custom UI and config. - return false; - } - - return !!$scope.getOIDCProviderId(key); - }); - }; - - $scope.getServices = function(config) { - var services = []; - if (!config) { return services; } - - for (var i = 0; i < $scope.SERVICES.length; ++i) { - var service = $scope.SERVICES[i]; - if (!service.condition || service.condition(config)) { - services.push({ - 'service': service, - 'status': 'validating' - }); - } - } - - return services; - }; - - $scope.validationStatus = function(serviceInfos) { - if (!serviceInfos) { return 'validating'; } - - var hasError = false; - for (var i = 0; i < serviceInfos.length; ++i) { - if (serviceInfos[i].status == 'validating') { - return 'validating'; - } - if (serviceInfos[i].status == 'error') { - hasError = true; - } - } - - return hasError ? 'failed' : 'success'; - }; - - $scope.cancelValidation = function() { - $('#validateAndSaveModal').modal('hide'); - $scope.validating = null; - $scope.savingConfiguration = false; - }; - - $scope.validateService = function(serviceInfo, opt_password) { - var params = { - 'service': serviceInfo.service.id - }; - - var data = { - 'config': $scope.config, - 'password': opt_password || '' - }; - - var errorDisplay = ApiService.errorDisplay( - 'Could not validate configuration. Please report this error.', - function() { - authPassword = null; - }); - - ApiService.scValidateConfig(data, params).then(function(resp) { - serviceInfo.status = resp.status ? 'success' : 'error'; - serviceInfo.errorMessage = $.trim(resp.reason || ''); - - if (!resp.status) { - authPassword = null; - } - - }, errorDisplay); - }; - - $scope.checkValidateAndSave = function() { - if ($scope.configform.$valid) { - saveStorageConfig(); - $scope.validateAndSave(); - return; - } - - var query = $element.find("input.ng-invalid:first"); - - if (query && query.length) { - query[0].scrollIntoView(); - query.focus(); - } - }; - - $scope.validateAndSave = function() { - $scope.validating = $scope.getServices($scope.config); - - var requirePassword = false; - for (var i = 0; i < $scope.validating.length; ++i) { - var serviceInfo = $scope.validating[i]; - if (serviceInfo.service.password) { - requirePassword = true; - break; - } - } - - if (!requirePassword) { - $scope.performValidateAndSave(); - return; - } - - var box = bootbox.dialog({ - "message": 'Please enter your superuser password to validate your auth configuration:' + - '
' + - '' + - '
', - "title": 'Enter Password', - "buttons": { - "success": { - "label": "Validate Config", - "className": "btn-success btn-continue", - "callback": function() { - $scope.performValidateAndSave($('#validatePassword').val()); - } - }, - "close": { - "label": "Cancel", - "className": "btn-default", - "callback": function() { - } - } - } - }); - - box.bind('shown.bs.modal', function(){ - box.find("input").focus(); - box.find("form").submit(function() { - if (!$('#validatePassword').val()) { return; } - box.modal('hide'); - }); - }); - }; - - $scope.performValidateAndSave = function(opt_password) { - $scope.savingConfiguration = false; - $scope.validating = $scope.getServices($scope.config); - - authPassword = opt_password; - - $('#validateAndSaveModal').modal({ - keyboard: false, - backdrop: 'static' - }); - - for (var i = 0; i < $scope.validating.length; ++i) { - var serviceInfo = $scope.validating[i]; - $scope.validateService(serviceInfo, opt_password); - } - }; - - $scope.saveConfiguration = function() { - $scope.savingConfiguration = true; - - // Make sure to note that fully verified setup is completed. We use this as a signal - // in the setup tool. - $scope.config['SETUP_COMPLETE'] = true; - - var data = { - 'config': $scope.config, - 'hostname': window.location.host, - 'password': authPassword || '' - }; - - var errorDisplay = ApiService.errorDisplay( - 'Could not save configuration. Please report this error.', - function() { - authPassword = null; - }); - - ApiService.scUpdateConfig(data).then(function(resp) { - authPassword = null; - - $scope.savingConfiguration = false; - $scope.mapped.$hasChanges = false; - - $('#validateAndSaveModal').modal('hide'); - - $scope.configurationSaved({'config': $scope.config}); - }, errorDisplay); - }; - - // Convert storage config to an array - var initializeStorageConfig = function($scope) { - var config = $scope.config.DISTRIBUTED_STORAGE_CONFIG || {}; - var defaultLocations = $scope.config.DISTRIBUTED_STORAGE_DEFAULT_LOCATIONS || []; - var preference = $scope.config.DISTRIBUTED_STORAGE_PREFERENCE || []; - - $scope.serverStorageConfig = angular.copy(config); - $scope.storageConfig = []; - - Object.keys(config).forEach(function(location) { - $scope.storageConfig.push({ - location: location, - defaultLocation: defaultLocations.indexOf(location) >= 0, - data: angular.copy(config[location]), - error: {}, - }); - }); - - if (!$scope.storageConfig.length) { - $scope.addStorageConfig('default'); - return; - } - - // match DISTRIBUTED_STORAGE_PREFERENCE order first, remaining are - // ordered by unicode point value - $scope.storageConfig.sort(function(a, b) { - var indexA = preference.indexOf(a.location); - var indexB = preference.indexOf(b.location); - - if (indexA > -1 && indexB > -1) return indexA < indexB ? -1 : 1; - if (indexA > -1) return -1; - if (indexB > -1) return 1; - - return a.location < b.location ? -1 : 1; - }); - }; - - $scope.allowChangeLocationStorageConfig = function(location) { - if (!$scope.serverStorageConfig[location]) { return true }; - - // allow user to change location ID if another exists with the same ID - return $scope.storageConfig.filter(function(sc) { - return sc.location === location; - }).length >= 2; - }; - - $scope.allowRemoveStorageConfig = function(location) { - return $scope.storageConfig.length > 1 && $scope.allowChangeLocationStorageConfig(location); - }; - - $scope.canAddStorageConfig = function() { - return $scope.config && - $scope.config.FEATURE_STORAGE_REPLICATION && - $scope.storageConfig && - (!$scope.storageConfig.length || $scope.storageConfig.length < 10); - }; - - $scope.addStorageConfig = function(location) { - var storageType = 'LocalStorage'; - - // Use last storage type by default - if ($scope.storageConfig.length) { - storageType = $scope.storageConfig[$scope.storageConfig.length-1].data[0]; - } - - $scope.storageConfig.push({ - location: location || '', - defaultLocation: false, - data: [storageType, {}], - error: {}, - }); - }; - - $scope.removeStorageConfig = function(sc) { - $scope.storageConfig.splice($scope.storageConfig.indexOf(sc), 1); - }; - - var saveStorageConfig = function() { - var config = {}; - var defaultLocations = []; - var preference = []; - - $scope.storageConfig.forEach(function(sc) { - config[sc.location] = sc.data; - if (sc.defaultLocation) defaultLocations.push(sc.location); - preference.push(sc.location); - }); - - $scope.config.DISTRIBUTED_STORAGE_CONFIG = config; - $scope.config.DISTRIBUTED_STORAGE_DEFAULT_LOCATIONS = defaultLocations; - $scope.config.DISTRIBUTED_STORAGE_PREFERENCE = preference; - }; - - var gitlabSelector = function(key) { - return function(value) { - if (!value || !$scope.config) { return; } - - if (!$scope.config[key]) { - $scope.config[key] = {}; - } - - if (value == 'enterprise') { - if ($scope.config[key]['GITLAB_ENDPOINT'] == 'https://gitlab.com/') { - $scope.config[key]['GITLAB_ENDPOINT'] = ''; - } - } else if (value == 'hosted') { - $scope.config[key]['GITLAB_ENDPOINT'] = 'https://gitlab.com/'; - } - }; - }; - - var githubSelector = function(key) { - return function(value) { - if (!value || !$scope.config) { return; } - - if (!$scope.config[key]) { - $scope.config[key] = {}; - } - - if (value == 'enterprise') { - if ($scope.config[key]['GITHUB_ENDPOINT'] == 'https://github.com/') { - $scope.config[key]['GITHUB_ENDPOINT'] = ''; - } - delete $scope.config[key]['API_ENDPOINT']; - } else if (value == 'hosted') { - $scope.config[key]['GITHUB_ENDPOINT'] = 'https://github.com/'; - $scope.config[key]['API_ENDPOINT'] = 'https://api.github.com/'; - } - }; - }; - - var getKey = function(config, path) { - if (!config) { - return null; - } - - var parts = path.split('.'); - var current = config; - for (var i = 0; i < parts.length; ++i) { - var part = parts[i]; - if (!current[part]) { return null; } - current = current[part]; - } - return current; - }; - - var initializeMappedLogic = function(config) { - var gle = getKey(config, 'GITHUB_LOGIN_CONFIG.GITHUB_ENDPOINT'); - var gte = getKey(config, 'GITHUB_TRIGGER_CONFIG.GITHUB_ENDPOINT'); - - $scope.mapped['GITHUB_LOGIN_KIND'] = gle == 'https://github.com/' ? 'hosted' : 'enterprise'; - $scope.mapped['GITHUB_TRIGGER_KIND'] = gte == 'https://github.com/' ? 'hosted' : 'enterprise'; - - var glabe = getKey(config, 'GITLAB_TRIGGER_KIND.GITHUB_ENDPOINT'); - $scope.mapped['GITLAB_TRIGGER_KIND'] = glabe == 'https://gitlab.com/' ? 'hosted' : 'enterprise'; - - $scope.mapped['redis'] = {}; - $scope.mapped['redis']['host'] = getKey(config, 'BUILDLOGS_REDIS.host') || getKey(config, 'USER_EVENTS_REDIS.host'); - $scope.mapped['redis']['port'] = getKey(config, 'BUILDLOGS_REDIS.port') || getKey(config, 'USER_EVENTS_REDIS.port'); - $scope.mapped['redis']['password'] = getKey(config, 'BUILDLOGS_REDIS.password') || getKey(config, 'USER_EVENTS_REDIS.password'); - - $scope.mapped['TLS_SETTING'] = 'none'; - if (config['PREFERRED_URL_SCHEME'] == 'https') { - if (config['EXTERNAL_TLS_TERMINATION'] === true) { - $scope.mapped['TLS_SETTING'] = 'external-tls'; - } else { - $scope.mapped['TLS_SETTING'] = 'internal-tls'; - } - } - }; - - var tlsSetter = function(value) { - if (value == null || !$scope.config) { return; } - - switch (value) { - case 'none': - $scope.config['PREFERRED_URL_SCHEME'] = 'http'; - delete $scope.config['EXTERNAL_TLS_TERMINATION']; - return; - - case 'external-tls': - $scope.config['PREFERRED_URL_SCHEME'] = 'https'; - $scope.config['EXTERNAL_TLS_TERMINATION'] = true; - return; - - case 'internal-tls': - $scope.config['PREFERRED_URL_SCHEME'] = 'https'; - delete $scope.config['EXTERNAL_TLS_TERMINATION']; - return; - } - }; - - var redisSetter = function(keyname) { - return function(value) { - if (value == null || !$scope.config) { return; } - - if (!$scope.config['BUILDLOGS_REDIS']) { - $scope.config['BUILDLOGS_REDIS'] = {}; - } - - if (!$scope.config['USER_EVENTS_REDIS']) { - $scope.config['USER_EVENTS_REDIS'] = {}; - } - - if (!value) { - delete $scope.config['BUILDLOGS_REDIS'][keyname]; - delete $scope.config['USER_EVENTS_REDIS'][keyname]; - return; - } - - $scope.config['BUILDLOGS_REDIS'][keyname] = value; - $scope.config['USER_EVENTS_REDIS'][keyname] = value; - }; - }; - - // Add mapped logic. - $scope.$watch('mapped.GITHUB_LOGIN_KIND', githubSelector('GITHUB_LOGIN_CONFIG')); - $scope.$watch('mapped.GITHUB_TRIGGER_KIND', githubSelector('GITHUB_TRIGGER_CONFIG')); - $scope.$watch('mapped.GITLAB_TRIGGER_KIND', gitlabSelector('GITLAB_TRIGGER_KIND')); - $scope.$watch('mapped.TLS_SETTING', tlsSetter); - - $scope.$watch('mapped.redis.host', redisSetter('host')); - $scope.$watch('mapped.redis.port', redisSetter('port')); - $scope.$watch('mapped.redis.password', redisSetter('password')); - - // Remove extra extra fields (which are not allowed) from storage config. - var updateFields = function(sc) { - var type = sc.data[0]; - var configObject = sc.data[1]; - var allowedFields = $scope.STORAGE_CONFIG_FIELDS[type]; - - // Remove any fields not allowed. - for (var fieldName in configObject) { - if (!configObject.hasOwnProperty(fieldName)) { - continue; - } - - var isValidField = $.grep(allowedFields, function(field) { - return field.name == fieldName; - }).length > 0; - - if (!isValidField) { - delete configObject[fieldName]; - } - } - - // Set any missing boolean fields to false. - for (var i = 0; i < allowedFields.length; ++i) { - if (allowedFields[i].kind == 'bool') { - configObject[allowedFields[i].name] = configObject[allowedFields[i].name] || false; - } - } - }; - - // Validate and update storage config on update. - var refreshStorageConfig = function() { - if (!$scope.config || !$scope.storageConfig) return; - - var locationCounts = {}; - var errors = []; - var valid = true; - - $scope.storageConfig.forEach(function(sc) { - // remove extra fields from storage config - updateFields(sc); - - if (!locationCounts[sc.location]) locationCounts[sc.location] = 0; - locationCounts[sc.location]++; - }); - - // validate storage config - $scope.storageConfig.forEach(function(sc) { - var error = {}; - - if ($scope.config.FEATURE_STORAGE_REPLICATION && sc.data[0] === 'LocalStorage') { - error.engine = 'Replication to a locally mounted directory is unsupported as it is only accessible on a single machine.'; - valid = false; - } - - if (locationCounts[sc.location] > 1) { - error.location = 'Location ID must be unique.'; - valid = false; - } - - errors.push(error); - }); - - $scope.storageConfigError = errors; - $scope.configform.$setValidity('storageConfig', valid); - }; - - $scope.$watch('config.INTERNAL_OIDC_SERVICE_ID', function(service_id) { - if (service_id) { - $scope.config['FEATURE_DIRECT_LOGIN'] = false; - } - }); - - $scope.$watch('config.FEATURE_STORAGE_REPLICATION', function() { - refreshStorageConfig(); - }); - - $scope.$watch('storageConfig', function() { - refreshStorageConfig(); - }, true); - - $scope.$watch('config', function(value) { - $scope.mapped['$hasChanges'] = true; - }, true); - - $scope.$watch('isActive', function(value) { - if (!value) { return; } - - ApiService.scGetConfig().then(function(resp) { - $scope.config = resp['config'] || {}; - initializeMappedLogic($scope.config); - initializeStorageConfig($scope); - $scope.mapped['$hasChanges'] = false; - }, ApiService.errorDisplay('Could not load config')); - }); - } - }; - - return directiveDefinitionObject; - }) - - .directive('configParsedField', function ($timeout) { - var directiveDefinitionObject = { - priority: 0, - templateUrl: '/static/directives/config/config-parsed-field.html', - replace: false, - transclude: true, - restrict: 'C', - scope: { - 'binding': '=binding', - 'parser': '&parser', - 'serializer': '&serializer' - }, - controller: function($scope, $element, $transclude) { - $scope.childScope = null; - - $transclude(function(clone, scope) { - $scope.childScope = scope; - $scope.childScope['fields'] = {}; - $element.append(clone); - }); - - $scope.childScope.$watch('fields', function(value) { - // Note: We need the timeout here because Angular starts the digest of the - // parent scope AFTER the child scope, which means it can end up one action - // behind. The timeout ensures that the parent scope will be fully digest-ed - // and then we update the binding. Yes, this is a hack :-/. - $timeout(function() { - $scope.binding = $scope.serializer({'fields': value}); - }); - }, true); - - $scope.$watch('binding', function(value) { - var parsed = $scope.parser({'value': value}); - for (var key in parsed) { - if (parsed.hasOwnProperty(key)) { - $scope.childScope['fields'][key] = parsed[key]; - } - } - }); - } - }; - return directiveDefinitionObject; - }) - - .directive('configVariableField', function () { - var directiveDefinitionObject = { - priority: 0, - templateUrl: '/static/directives/config/config-variable-field.html', - replace: false, - transclude: true, - restrict: 'C', - scope: { - 'binding': '=binding' - }, - controller: function($scope, $element) { - $scope.sections = {}; - $scope.currentSection = null; - - $scope.setSection = function(section) { - $scope.binding = section.value; - }; - - this.addSection = function(section, element) { - $scope.sections[section.value] = { - 'title': section.valueTitle, - 'value': section.value, - 'element': element - }; - - element.hide(); - - if (!$scope.binding) { - $scope.binding = section.value; - } - }; - - $scope.$watch('binding', function(binding) { - if (!binding) { return; } - - if ($scope.currentSection) { - $scope.currentSection.element.hide(); - } - - if ($scope.sections[binding]) { - $scope.sections[binding].element.show(); - $scope.currentSection = $scope.sections[binding]; - } - }); - } - }; - return directiveDefinitionObject; - }) - - .directive('variableSection', function () { - var directiveDefinitionObject = { - priority: 0, - templateUrl: '/static/directives/config/config-variable-field.html', - priority: 1, - require: '^configVariableField', - replace: false, - transclude: true, - restrict: 'C', - scope: { - 'value': '@value', - 'valueTitle': '@valueTitle' - }, - controller: function($scope, $element) { - var parentCtrl = $element.parent().controller('configVariableField'); - parentCtrl.addSection($scope, $element); - } - }; - return directiveDefinitionObject; - }) - - .directive('configListField', function () { - var directiveDefinitionObject = { - priority: 0, - templateUrl: '/static/directives/config/config-list-field.html', - replace: false, - transclude: false, - restrict: 'C', - scope: { - 'binding': '=binding', - 'placeholder': '@placeholder', - 'defaultValue': '@defaultValue', - 'itemTitle': '@itemTitle', - 'itemPattern': '@itemPattern' - }, - controller: function($scope, $element) { - $scope.removeItem = function(item) { - var index = $scope.binding.indexOf(item); - if (index >= 0) { - $scope.binding.splice(index, 1); - } - }; - - $scope.addItem = function() { - if (!$scope.newItemName) { - return; - } - - if (!$scope.binding) { - $scope.binding = []; - } - - if ($scope.binding.indexOf($scope.newItemName) >= 0) { - return; - } - - $scope.binding.push($scope.newItemName); - $scope.newItemName = null; - }; - - $scope.patternMap = {}; - - $scope.getRegexp = function(pattern) { - if (!pattern) { - pattern = '.*'; - } - - if ($scope.patternMap[pattern]) { - return $scope.patternMap[pattern]; - } - - return $scope.patternMap[pattern] = new RegExp(pattern); - }; - - $scope.$watch('binding', function(binding) { - if (!binding && $scope.defaultValue) { - $scope.binding = eval($scope.defaultValue); - } - }); - } - }; - return directiveDefinitionObject; - }) - - .directive('configFileField', function () { - var directiveDefinitionObject = { - priority: 0, - templateUrl: '/static/directives/config/config-file-field.html', - replace: false, - transclude: false, - restrict: 'C', - scope: { - 'filename': '@filename', - 'skipCheckFile': '@skipCheckFile', - 'hasFile': '=hasFile', - 'binding': '=?binding' - }, - controller: function($scope, $element, Restangular, $upload) { - $scope.hasFile = false; - - var setHasFile = function(hasFile) { - $scope.hasFile = hasFile; - $scope.binding = hasFile ? $scope.filename : null; - }; - - $scope.onFileSelect = function(files) { - if (files.length < 1) { - setHasFile(false); - return; - } - - $scope.uploadProgress = 0; - $scope.upload = $upload.upload({ - url: '/api/v1/superuser/config/file/' + $scope.filename, - method: 'POST', - data: {'_csrf_token': window.__token}, - file: files[0], - }).progress(function(evt) { - $scope.uploadProgress = parseInt(100.0 * evt.loaded / evt.total); - if ($scope.uploadProgress == 100) { - $scope.uploadProgress = null; - setHasFile(true); - } - }).success(function(data, status, headers, config) { - $scope.uploadProgress = null; - setHasFile(true); - }); - }; - - var loadStatus = function(filename) { - Restangular.one('superuser/config/file/' + filename).get().then(function(resp) { - setHasFile(false); - }); - }; - - if ($scope.filename && $scope.skipCheckFile != "true") { - loadStatus($scope.filename); - } - } - }; - return directiveDefinitionObject; - }) - - .directive('configBoolField', function () { - var directiveDefinitionObject = { - priority: 0, - templateUrl: '/static/directives/config/config-bool-field.html', - replace: false, - transclude: true, - restrict: 'C', - scope: { - 'binding': '=binding' - }, - controller: function($scope, $element) { - } - }; - return directiveDefinitionObject; - }) - - .directive('configNumericField', function () { - var directiveDefinitionObject = { - priority: 0, - templateUrl: '/static/directives/config/config-numeric-field.html', - replace: false, - transclude: false, - restrict: 'C', - scope: { - 'binding': '=binding', - 'placeholder': '@placeholder', - 'defaultValue': '@defaultValue', - }, - controller: function($scope, $element) { - $scope.bindinginternal = 0; - - $scope.$watch('binding', function(binding) { - if ($scope.binding == 0 && $scope.defaultValue) { - $scope.binding = $scope.defaultValue * 1; - } - - $scope.bindinginternal = $scope.binding; - }); - - $scope.$watch('bindinginternal', function(binding) { - var newValue = $scope.bindinginternal * 1; - if (isNaN(newValue)) { - newValue = 0; - } - $scope.binding = newValue; - }); - } - }; - return directiveDefinitionObject; - }) - - .directive('configContactsField', function () { - var directiveDefinitionObject = { - priority: 0, - templateUrl: '/static/directives/config/config-contacts-field.html', - priority: 1, - replace: false, - transclude: false, - restrict: 'C', - scope: { - 'binding': '=binding' - }, - controller: function($scope, $element) { - var padItems = function(items) { - // Remove the last item if both it and the second to last items are empty. - if (items.length > 1 && !items[items.length - 2].value && !items[items.length - 1].value) { - items.splice(items.length - 1, 1); - return; - } - - // If the last item is non-empty, add a new item. - if (items.length == 0 || items[items.length - 1].value) { - items.push({'value': ''}); - return; - } - }; - - $scope.itemHash = null; - $scope.$watch('items', function(items) { - if (!items) { return; } - padItems(items); - - var itemHash = ''; - var binding = []; - for (var i = 0; i < items.length; ++i) { - var item = items[i]; - if (item.value && (URI(item.value).host() || URI(item.value).path())) { - binding.push(item.value); - itemHash += item.value; - } - } - - $scope.itemHash = itemHash; - $scope.binding = binding; - }, true); - - $scope.$watch('binding', function(binding) { - var current = binding || []; - var items = []; - var itemHash = ''; - for (var i = 0; i < current.length; ++i) { - items.push({'value': current[i]}) - itemHash += current[i]; - } - - if ($scope.itemHash != itemHash) { - $scope.items = items; - } - }); - } - }; - return directiveDefinitionObject; - }) - - .directive('configContactField', function () { - var directiveDefinitionObject = { - priority: 0, - templateUrl: '/static/directives/config/config-contact-field.html', - priority: 1, - replace: false, - transclude: true, - restrict: 'C', - scope: { - 'binding': '=binding' - }, - controller: function($scope, $element) { - $scope.kind = null; - $scope.value = null; - - var updateBinding = function() { - if ($scope.value == null) { return; } - var value = $scope.value || ''; - if (!value) { - $scope.binding = ''; - return; - } - - switch ($scope.kind) { - case 'mailto': - $scope.binding = 'mailto:' + value; - return; - - case 'tel': - $scope.binding = 'tel:' + value; - return; - - case 'irc': - $scope.binding = 'irc://' + value; - return; - - default: - $scope.binding = value; - return; - } - }; - - $scope.$watch('kind', updateBinding); - $scope.$watch('value', updateBinding); - - $scope.$watch('binding', function(value) { - if (!value) { - $scope.kind = null; - $scope.value = null; - return; - } - - var uri = URI(value); - $scope.kind = uri.scheme(); - - switch ($scope.kind) { - case 'mailto': - case 'tel': - $scope.value = uri.path(); - break; - - case 'irc': - $scope.value = value.substr('irc://'.length); - break; - - default: - $scope.kind = 'http'; - $scope.value = value; - break; - } - }); - - $scope.getPlaceholder = function(kind) { - switch (kind) { - case 'mailto': - return 'some@example.com'; - - case 'tel': - return '555-555-5555'; - - case 'irc': - return 'myserver:port/somechannel'; - - default: - return 'http://some/url'; - } - }; - } - }; - return directiveDefinitionObject; - }) - - .directive('configMapField', function () { - var directiveDefinitionObject = { - priority: 0, - templateUrl: '/static/directives/config/config-map-field.html', - replace: false, - transclude: false, - restrict: 'C', - scope: { - 'binding': '=binding', - 'keys': '=keys' - }, - controller: function($scope, $element) { - $scope.newKey = null; - $scope.newValue = null; - - $scope.hasValues = function(binding) { - return binding && Object.keys(binding).length; - }; - - $scope.removeKey = function(key) { - delete $scope.binding[key]; - }; - - $scope.addEntry = function() { - if (!$scope.newKey || !$scope.newValue) { return; } - - $scope.binding = $scope.binding || {}; - $scope.binding[$scope.newKey] = $scope.newValue; - $scope.newKey = null; - $scope.newValue = null; - } - } - }; - return directiveDefinitionObject; - }) - - .directive('configServiceKeyField', function (ApiService) { - var directiveDefinitionObject = { - priority: 0, - templateUrl: '/static/directives/config/config-service-key-field.html', - replace: false, - transclude: false, - restrict: 'C', - scope: { - 'serviceName': '@serviceName', - }, - controller: function($scope, $element) { - $scope.foundKeys = []; - $scope.loading = false; - $scope.loadError = false; - $scope.hasValidKey = false; - $scope.hasValidKeyStr = null; - - $scope.updateKeys = function() { - $scope.foundKeys = []; - $scope.loading = true; - - ApiService.listServiceKeys().then(function(resp) { - $scope.loading = false; - $scope.loadError = false; - - resp['keys'].forEach(function(key) { - if (key['service'] == $scope.serviceName) { - $scope.foundKeys.push(key); - } - }); - - $scope.hasValidKey = checkValidKey($scope.foundKeys); - $scope.hasValidKeyStr = $scope.hasValidKey ? 'true' : ''; - }, function() { - $scope.loading = false; - $scope.loadError = true; - }); - }; - - // Perform initial loading of the keys. - $scope.updateKeys(); - - $scope.isKeyExpired = function(key) { - if (key.expiration_date != null) { - var expiration_date = moment(key.expiration_date); - return moment().isAfter(expiration_date); - } - return false; - }; - - $scope.showRequestServiceKey = function() { - $scope.requestKeyInfo = { - 'service': $scope.serviceName - }; - }; - - $scope.handleKeyCreated = function() { - $scope.updateKeys(); - }; - - var checkValidKey = function(keys) { - for (var i = 0; i < keys.length; ++i) { - var key = keys[i]; - if (!key.approval) { - continue; - } - - if ($scope.isKeyExpired(key)) { - continue; - } - - return true; - } - - return false; - }; - } - }; - return directiveDefinitionObject; - }) - - .directive('configStringField', function () { - var directiveDefinitionObject = { - priority: 0, - templateUrl: '/static/directives/config/config-string-field.html', - replace: false, - transclude: false, - restrict: 'C', - scope: { - 'binding': '=binding', - 'placeholder': '@placeholder', - 'pattern': '@pattern', - 'defaultValue': '@defaultValue', - 'validator': '&validator', - 'isOptional': '=isOptional' - }, - controller: function($scope, $element) { - var firstSet = true; - - $scope.patternMap = {}; - - $scope.getRegexp = function(pattern) { - if (!pattern) { - pattern = '.*'; - } - - if ($scope.patternMap[pattern]) { - return $scope.patternMap[pattern]; - } - - return $scope.patternMap[pattern] = new RegExp(pattern); - }; - - $scope.$watch('binding', function(binding) { - if (firstSet && !binding && $scope.defaultValue) { - $scope.binding = $scope.defaultValue; - firstSet = false; - } - - $scope.errorMessage = $scope.validator({'value': binding || ''}); - }); - } - }; - return directiveDefinitionObject; - }) - - .directive('configStringListField', function () { - var directiveDefinitionObject = { - priority: 0, - templateUrl: '/static/directives/config/config-string-list-field.html', - replace: false, - transclude: false, - restrict: 'C', - scope: { - 'binding': '=binding', - 'itemTitle': '@itemTitle', - 'itemDelimiter': '@itemDelimiter', - 'placeholder': '@placeholder', - 'isOptional': '=isOptional' - }, - controller: function($scope, $element) { - $scope.$watch('internalBinding', function(value) { - if (value) { - $scope.binding = value.split($scope.itemDelimiter); - } - }); - - $scope.$watch('binding', function(value) { - if (value) { - $scope.internalBinding = value.join($scope.itemDelimiter); - } - }); - } - }; - return directiveDefinitionObject; - }) - - .directive('configCertificatesField', function () { - var directiveDefinitionObject = { - priority: 0, - templateUrl: '/static/directives/config/config-certificates-field.html', - replace: false, - transclude: false, - restrict: 'C', - scope: { - }, - controller: function($scope, $element, $upload, ApiService, UserService) { - $scope.resetUpload = 0; - $scope.certsUploading = false; - - var loadCertificates = function() { - $scope.certificatesResource = ApiService.getCustomCertificatesAsResource().get(function(resp) { - $scope.certInfo = resp; - $scope.certsUploading = false; - }); - }; - - UserService.updateUserIn($scope, function(user) { - if (!user.anonymous) { - loadCertificates(); - } - }); - - $scope.handleCertsSelected = function(files, callback) { - $scope.certsUploading = true; - $upload.upload({ - url: '/api/v1/superuser/customcerts/' + files[0].name, - method: 'POST', - data: {'_csrf_token': window.__token}, - file: files[0] - }).success(function() { - callback(true); - $scope.resetUpload++; - loadCertificates(); - }).error(function(r) { - bootbox.alert('Could not upload certificate') - callback(false); - $scope.resetUpload++; - loadCertificates(); - }); - }; - - $scope.deleteCert = function(path) { - var errorDisplay = ApiService.errorDisplay('Could not delete certificate'); - var params = { - 'certpath': path - }; - - ApiService.deleteCustomCertificate(null, params).then(loadCertificates, errorDisplay); - }; - } - }; - return directiveDefinitionObject; - }); diff --git a/static/js/core-ui.js b/static/js/core-ui.js index 9b57e529b..cd031649e 100644 --- a/static/js/core-ui.js +++ b/static/js/core-ui.js @@ -318,39 +318,6 @@ angular.module("core-ui", []) return directiveDefinitionObject; }) - .directive('corStepBar', function() { - var directiveDefinitionObject = { - priority: 4, - templateUrl: '/static/directives/cor-step-bar.html', - 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('corCheckableMenu', function() { var directiveDefinitionObject = { priority: 1, diff --git a/static/js/directives/ui/request-service-key-dialog.js b/static/js/directives/ui/request-service-key-dialog.js deleted file mode 100644 index bd3351567..000000000 --- a/static/js/directives/ui/request-service-key-dialog.js +++ /dev/null @@ -1,124 +0,0 @@ -/** - * An element which displays a dialog for requesting or creating a service key. - */ -angular.module('quay').directive('requestServiceKeyDialog', function () { - var directiveDefinitionObject = { - priority: 0, - templateUrl: '/static/directives/request-service-key-dialog.html', - replace: false, - transclude: true, - restrict: 'C', - scope: { - 'requestKeyInfo': '=requestKeyInfo', - 'keyCreated': '&keyCreated' - }, - controller: function($scope, $element, $timeout, ApiService) { - var handleNewKey = function(key) { - var data = { - 'notes': 'Approved during setup of service ' + key.service - }; - - var params = { - 'kid': key.kid - }; - - ApiService.approveServiceKey(data, params).then(function(resp) { - $scope.keyCreated({'key': key}); - $scope.step = 2; - }, ApiService.errorDisplay('Could not approve service key')); - }; - - var checkKeys = function() { - var isShown = ($element.find('.modal').data('bs.modal') || {}).isShown; - if (!isShown) { - return; - } - - // TODO: filter by service. - ApiService.listServiceKeys().then(function(resp) { - var keys = resp['keys']; - for (var i = 0; i < keys.length; ++i) { - var key = keys[i]; - if (key.service == $scope.requestKeyInfo.service && !key.approval && key.rotation_duration) { - handleNewKey(key); - return; - } - } - - $timeout(checkKeys, 1000); - }, ApiService.errorDisplay('Could not list service keys')); - }; - - $scope.show = function() { - $scope.working = false; - $scope.step = 0; - $scope.requestKind = null; - $scope.preshared = { - 'name': $scope.requestKeyInfo.service + ' Service Key', - 'notes': 'Created during setup for service `' + $scope.requestKeyInfo.service + '`' - }; - - $element.find('.modal').modal({}); - }; - - $scope.hide = function() { - $scope.loading = false; - $element.find('.modal').modal('hide'); - }; - - $scope.showGenerate = function() { - $scope.step = 1; - }; - - $scope.startApproval = function() { - $scope.step = 1; - checkKeys(); - }; - - $scope.isDownloadSupported = function() { - var isSafari = /^((?!chrome).)*safari/i.test(navigator.userAgent); - if (isSafari) { - // Doesn't work properly in Safari, sadly. - return false; - } - - try { return !!new Blob(); } catch(e) {} - return false; - }; - - $scope.downloadPrivateKey = function(key) { - var blob = new Blob([key.private_key]); - FileSaver.saveAs(blob, key.service + '.pem'); - }; - - $scope.createPresharedKey = function() { - $scope.working = true; - - var data = { - 'name': $scope.preshared.name, - 'service': $scope.requestKeyInfo.service, - 'expiration': $scope.preshared.expiration || null, - 'notes': $scope.preshared.notes - }; - - ApiService.createServiceKey(data).then(function(resp) { - $scope.working = false; - $scope.step = 2; - $scope.createdKey = resp; - $scope.keyCreated({'key': resp}); - }, ApiService.errorDisplay('Could not create service key')); - }; - - $scope.updateNotes = function(content) { - $scope.preshared.notes = content; - }; - - $scope.$watch('requestKeyInfo', function(info) { - if (info && info.service) { - $scope.show(); - } - }); - } - }; - return directiveDefinitionObject; -}); \ No newline at end of file diff --git a/static/js/pages/incomplete-setup.js b/static/js/pages/incomplete-setup.js new file mode 100644 index 000000000..8a376d743 --- /dev/null +++ b/static/js/pages/incomplete-setup.js @@ -0,0 +1,82 @@ +(function() { + /** + * The Incomplete Setup page provides information to the user about what's wrong with the current configuration + */ + angular.module('quayPages').config(['pages', function(pages) { + pages.create('incomplete-setup', 'incomplete-setup.html', IncompleteSetupCtrl, + { + 'newLayout': true, + 'title': 'Quay Enterprise Setup Incomplete' + }) + }]); + + function IncompleteSetupCtrl($scope, $timeout, ApiService, Features, UserService, ContainerService, CoreDialog) { + if (!Features.SUPER_USERS) { + return; + } + + $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', + }; + + $scope.currentStep = $scope.States.LOADING; + + $scope.$watch('currentStep', function(currentStep) { + switch (currentStep) { + case $scope.States.MISSING_CONFIG_DIR: + $scope.showMissingConfigDialog(); + break; + + case $scope.States.INVALID_CONFIG: + $scope.showInvalidConfigDialog(); + break; + } + }); + + $scope.showInvalidConfigDialog = function() { + var message = "The config.yaml file found in conf/stack could not be parsed." + var title = "Invalid configuration file"; + CoreDialog.fatal(title, message); + }; + + + $scope.showMissingConfigDialog = function() { + var title = "Missing configuration volume"; + var message = "It looks like Quay was not mounted with a configuration volume. The volume should be " + + "mounted into the container at /conf/stack. " + + "
If you have a tarball, please ensure you untar it into a directory and re-run this container with: " + + "

docker run -v /path/to/config:/conf/stack
" + + "
If you haven't configured your Quay instance, please run the container with: " + + "

docker run <name-of-image> config 
" + + "For more information, " + + "" + + "Read the Setup Guide"; + + if (window.__kubernetes_namespace) { + title = "Configuration Secret Missing"; + message = `It looks like the Quay Enterprise secret is not present in the namespace ${window.__kubernetes_namespace}.` + + "
Please double-check that the secret exists, or " + + "" + + "refer to the Setup Guide"; + } + + CoreDialog.fatal(title, message); + }; + + $scope.checkStatus = function() { + ContainerService.checkStatus(function(resp) { + $scope.currentStep = resp['status']; + }, $scope.currentConfig); + }; + + // Load the initial status. + $scope.checkStatus(); + }; +})(); \ No newline at end of file diff --git a/static/js/pages/landing.js b/static/js/pages/landing.js index 49dd076ef..251e4253b 100644 --- a/static/js/pages/landing.js +++ b/static/js/pages/landing.js @@ -15,7 +15,7 @@ $scope.userRegistered = false; if (!Config['SETUP_COMPLETE'] && !Features.BILLING) { - $location.path('/setup'); + $location.path('/incomplete-setup'); return; } diff --git a/static/js/pages/setup.js b/static/js/pages/setup.js deleted file mode 100644 index 0b938cd99..000000000 --- a/static/js/pages/setup.js +++ /dev/null @@ -1,325 +0,0 @@ -import * as URI from 'urijs'; - -(function() { - /** - * The Setup page provides a nice GUI walkthrough experience for setting up Quay Enterprise. - */ - angular.module('quayPages').config(['pages', function(pages) { - pages.create('setup', 'setup.html', SetupCtrl, - { - 'newLayout': true, - 'title': 'Quay Enterprise Setup' - }) - }]); - - function SetupCtrl($scope, $timeout, ApiService, Features, UserService, ContainerService, CoreDialog) { - if (!Features.SUPER_USERS) { - return; - } - - $scope.HOSTNAME_REGEX = '^[a-zA-Z-0-9_\.\-]+(:[0-9]+)?$'; - - $scope.validateHostname = function(hostname) { - if (hostname.indexOf('127.0.0.1') == 0 || hostname.indexOf('localhost') == 0) { - return 'Please specify a non-localhost hostname. "localhost" will refer to the container, not your machine.' - } - - return null; - }; - - // Note: The values of the enumeration are important for isStepFamily. For example, - // *all* states under the "configuring db" family must start with "config-db". - $scope.States = { - // Loading the state of the product. - 'LOADING': 'loading', - - // The configuration directory is missing. - 'MISSING_CONFIG_DIR': 'missing-config-dir', - - // The config.yaml exists but it is invalid. - 'INVALID_CONFIG': 'config-invalid', - - // DB is being configured. - 'CONFIG_DB': 'config-db', - - // DB information is being validated. - 'VALIDATING_DB': 'config-db-validating', - - // DB information is being saved to the config. - 'SAVING_DB': 'config-db-saving', - - // A validation error occurred with the database. - 'DB_ERROR': 'config-db-error', - - // Database is being setup. - 'DB_SETUP': 'setup-db', - - // Database setup has succeeded. - 'DB_SETUP_SUCCESS': 'setup-db-success', - - // An error occurred when setting up the database. - 'DB_SETUP_ERROR': 'setup-db-error', - - // The container is being restarted for the database changes. - 'DB_RESTARTING': 'setup-db-restarting', - - // A superuser is being configured. - 'CREATE_SUPERUSER': 'create-superuser', - - // The superuser is being created. - 'CREATING_SUPERUSER': 'create-superuser-creating', - - // An error occurred when setting up the superuser. - 'SUPERUSER_ERROR': 'create-superuser-error', - - // The superuser was created successfully. - 'SUPERUSER_CREATED': 'create-superuser-created', - - // General configuration is being setup. - 'CONFIG': 'config', - - // The configuration is fully valid. - 'VALID_CONFIG': 'valid-config', - - // The container is being restarted for the configuration changes. - 'CONFIG_RESTARTING': 'config-restarting', - - // The product is ready for use. - 'READY': 'ready' - } - - $scope.csrf_token = window.__token; - $scope.currentStep = $scope.States.LOADING; - $scope.errors = {}; - $scope.stepProgress = []; - $scope.hasSSL = false; - $scope.hostname = null; - $scope.currentConfig = null; - - $scope.currentState = { - 'hasDatabaseSSLCert': false - }; - - $scope.$watch('currentStep', function(currentStep) { - $scope.stepProgress = $scope.getProgress(currentStep); - - switch (currentStep) { - case $scope.States.CONFIG: - $('#setupModal').modal('hide'); - break; - - case $scope.States.MISSING_CONFIG_DIR: - $scope.showMissingConfigDialog(); - break; - - case $scope.States.INVALID_CONFIG: - $scope.showInvalidConfigDialog(); - break; - - case $scope.States.DB_SETUP: - $scope.performDatabaseSetup(); - // Fall-through. - - case $scope.States.CREATE_SUPERUSER: - case $scope.States.DB_RESTARTING: - case $scope.States.CONFIG_DB: - case $scope.States.VALID_CONFIG: - case $scope.States.READY: - $('#setupModal').modal({ - keyboard: false, - backdrop: 'static' - }); - break; - } - }); - - $scope.restartContainer = function(state) { - $scope.currentStep = state; - ContainerService.restartContainer(function() { - $scope.checkStatus() - }); - }; - - $scope.showSuperuserPanel = function() { - $('#setupModal').modal('hide'); - var prefix = $scope.hasSSL ? 'https' : 'http'; - var hostname = $scope.hostname; - if (!hostname) { - hostname = document.location.hostname; - if (document.location.port) { - hostname = hostname + ':' + document.location.port; - } - } - - window.location = prefix + '://' + hostname + '/superuser'; - }; - - $scope.configurationSaved = function(config) { - $scope.hasSSL = config['PREFERRED_URL_SCHEME'] == 'https'; - $scope.hostname = config['SERVER_HOSTNAME']; - $scope.currentConfig = config; - - $scope.currentStep = $scope.States.VALID_CONFIG; - }; - - $scope.getProgress = function(step) { - var isStep = $scope.isStep; - var isStepFamily = $scope.isStepFamily; - var States = $scope.States; - - return [ - isStepFamily(step, States.CONFIG_DB), - isStepFamily(step, States.DB_SETUP), - isStep(step, States.DB_RESTARTING), - isStepFamily(step, States.CREATE_SUPERUSER), - isStep(step, States.CONFIG), - isStep(step, States.VALID_CONFIG), - isStep(step, States.CONFIG_RESTARTING), - isStep(step, States.READY) - ]; - }; - - $scope.isStepFamily = function(step, family) { - if (!step) { return false; } - return step.indexOf(family) == 0; - }; - - $scope.isStep = function(step) { - for (var i = 1; i < arguments.length; ++i) { - if (arguments[i] == step) { - return true; - } - } - return false; - }; - - $scope.beginSetup = function() { - $scope.currentStep = $scope.States.CONFIG_DB; - }; - - $scope.showInvalidConfigDialog = function() { - var message = "The config.yaml file found in conf/stack could not be parsed." - var title = "Invalid configuration file"; - CoreDialog.fatal(title, message); - }; - - - $scope.showMissingConfigDialog = function() { - var message = "It looks like Quay was not mounted with a configuration volume. The volume should be " + - "mounted into the container at /conf/stack. " + - "
If you have a tarball, please ensure you untar it into a directory and re-run this container with: " + - "

docker run -v /path/to/config:/conf/stack
" + - "
If you haven't configured your Quay instance, please run the container with: " + - "

docker run <name-of-image> config 
" + - "For more information, " + - "" + - "Read the Setup Guide"; - - var title = "Missing configuration volume"; - CoreDialog.fatal(title, message); - }; - - $scope.parseDbUri = function(value) { - if (!value) { return null; } - - // Format: mysql+pymysql://:@/ - var uri = URI(value); - return { - 'kind': uri.protocol(), - 'username': uri.username(), - 'password': uri.password(), - 'server': uri.host(), - 'database': uri.path() ? uri.path().substr(1) : '' - }; - }; - - $scope.serializeDbUri = function(fields) { - if (!fields['server']) { return ''; } - if (!fields['database']) { return ''; } - - var uri = URI(); - try { - uri = uri && uri.host(fields['server']); - uri = uri && uri.protocol(fields['kind']); - uri = uri && uri.username(fields['username']); - uri = uri && uri.password(fields['password']); - uri = uri && uri.path('/' + (fields['database'] || '')); - uri = uri && uri.toString(); - } catch (ex) { - return ''; - } - - return uri; - }; - - $scope.createSuperUser = function() { - $scope.currentStep = $scope.States.CREATING_SUPERUSER; - ApiService.scCreateInitialSuperuser($scope.superUser, null).then(function(resp) { - UserService.load(); - $scope.checkStatus(); - }, function(resp) { - $scope.currentStep = $scope.States.SUPERUSER_ERROR; - $scope.errors.SuperuserCreationError = ApiService.getErrorMessage(resp, 'Could not create superuser'); - }); - }; - - $scope.performDatabaseSetup = function() { - $scope.currentStep = $scope.States.DB_SETUP; - ApiService.scSetupDatabase(null, null).then(function(resp) { - if (resp['error']) { - $scope.currentStep = $scope.States.DB_SETUP_ERROR; - $scope.errors.DatabaseSetupError = resp['error']; - } else { - $scope.currentStep = $scope.States.DB_SETUP_SUCCESS; - } - }, ApiService.errorDisplay('Could not setup database. Please report this to support.')) - }; - - $scope.validateDatabase = function() { - $scope.currentStep = $scope.States.VALIDATING_DB; - $scope.databaseInvalid = null; - - var data = { - 'config': { - 'DB_URI': $scope.databaseUri - }, - 'hostname': window.location.host - }; - - if ($scope.currentState.hasDatabaseSSLCert) { - data['config']['DB_CONNECTION_ARGS'] = { - 'ssl': { - 'ca': 'conf/stack/database.pem' - } - }; - } - - var params = { - 'service': 'database' - }; - - ApiService.scValidateConfig(data, params).then(function(resp) { - var status = resp.status; - - if (status) { - $scope.currentStep = $scope.States.SAVING_DB; - ApiService.scUpdateConfig(data, null).then(function(resp) { - $scope.checkStatus(); - }, ApiService.errorDisplay('Cannot update config. Please report this to support')); - } else { - $scope.currentStep = $scope.States.DB_ERROR; - $scope.errors.DatabaseValidationError = resp.reason; - } - }, ApiService.errorDisplay('Cannot validate database. Please report this to support')); - }; - - $scope.checkStatus = function() { - ContainerService.checkStatus(function(resp) { - $scope.currentStep = resp['status']; - }, $scope.currentConfig); - }; - - // Load the initial status. - $scope.checkStatus(); - }; -})(); \ No newline at end of file diff --git a/static/js/quay-config.module.ts b/static/js/quay-config.module.ts index 7ef10b3cb..8ce147187 100644 --- a/static/js/quay-config.module.ts +++ b/static/js/quay-config.module.ts @@ -18,7 +18,6 @@ var quayDependencies: string[] = [ 'pasvaz.bindonce', 'ansiToHtml', 'core-ui', - 'core-config-setup', 'infinite-scroll', 'ngTagsInput', ]; diff --git a/static/js/quay-routes.module.ts b/static/js/quay-routes.module.ts index 892b1599b..a7e055af3 100644 --- a/static/js/quay-routes.module.ts +++ b/static/js/quay-routes.module.ts @@ -49,8 +49,7 @@ function provideRoutes($routeProvider: ng.route.IRouteProvider, if (INJECTED_FEATURES.SUPER_USERS) { // QE Management routeBuilder.route('/superuser/', 'superuser') - // QE Setup - .route('/setup/', 'setup'); + .route('/incomplete-setup/', 'incomplete-setup'); } routeBuilder diff --git a/static/partials/incomplete-setup.html b/static/partials/incomplete-setup.html new file mode 100644 index 000000000..70d38a88f --- /dev/null +++ b/static/partials/incomplete-setup.html @@ -0,0 +1,21 @@ +
+
+
+
+ + Quay Enterprise Setup Incomplete +
+ +
+
+
Your configuration is not setup yet
+ +
+ +
+
+
diff --git a/static/partials/setup.html b/static/partials/setup.html deleted file mode 100644 index b89647fd2..000000000 --- a/static/partials/setup.html +++ /dev/null @@ -1,307 +0,0 @@ -
-
-
-
- - Quay Enterprise Setup -
- -
-
- - - - - - - - - - - -
Almost done!
-
Configure your Redis database and other settings below
-
- -
-
-
-
- - - diff --git a/static/partials/super-user.html b/static/partials/super-user.html index ed7ed14d2..a70db4a9e 100644 --- a/static/partials/super-user.html +++ b/static/partials/super-user.html @@ -46,10 +46,6 @@ tab-init="loadDebugServices()"> - - - @@ -62,15 +58,6 @@ - - -
- Warning: If you are running Quay Enterprise under multiple containers, additional actions are necessary in order to apply any configuration changes made here to the entire cluster. It is recommended to make (and validate) configuration changes here, then copy your configuration to all instances and restart them. -
-
-
-
diff --git a/templates/base.html b/templates/base.html index 8e7c28c4a..006104787 100644 --- a/templates/base.html +++ b/templates/base.html @@ -35,6 +35,7 @@ window.__auth_scopes = {{ scope_set|tojson|safe }}; window.__vuln_priority = {{ vuln_priority_set|tojson|safe }} window.__token = '{{ csrf_token() }}'; + window.__kubernetes_namespace = {{ kubernetes_namespace|tojson|safe }}; {% if error_code %} window.__error_code = {{ error_code }}; diff --git a/test/test_api_usage.py b/test/test_api_usage.py index a5df7b1e6..7337748f9 100644 --- a/test/test_api_usage.py +++ b/test/test_api_usage.py @@ -8,7 +8,6 @@ import re import json as py_json from mock import patch -from StringIO import StringIO from calendar import timegm from contextlib import contextmanager from httmock import urlmatch, HTTMock, all_requests @@ -18,7 +17,6 @@ from urlparse import urlparse, urlunparse, parse_qs from playhouse.test_utils import assert_query_count, _QueryLogHandler from cryptography.hazmat.primitives import serialization from cryptography.hazmat.backends import default_backend -from mockldap import MockLdap from endpoints.api import api_bp, api from endpoints.building import PreparedBuild @@ -71,16 +69,12 @@ from endpoints.api.permission import (RepositoryUserPermission, RepositoryTeamPe RepositoryTeamPermissionList, RepositoryUserPermissionList) from endpoints.api.superuser import ( SuperUserLogs, SuperUserManagement, SuperUserServiceKeyManagement, - SuperUserServiceKey, SuperUserServiceKeyApproval, SuperUserTakeOwnership, - SuperUserCustomCertificates, SuperUserCustomCertificate) + SuperUserServiceKey, SuperUserServiceKeyApproval, SuperUserTakeOwnership) from endpoints.api.globalmessages import ( GlobalUserMessage, GlobalUserMessages,) from endpoints.api.secscan import RepositoryImageSecurity, RepositoryManifestSecurity -from endpoints.api.suconfig import ( - SuperUserRegistryStatus, SuperUserConfig, SuperUserConfigFile, SuperUserCreateInitialSuperUser) from endpoints.api.manifest import RepositoryManifestLabels, ManageRepositoryManifestLabel -from util.security.test.test_ssl_util import generate_test_cert from util.morecollections import AttrDict try: @@ -3985,227 +3979,6 @@ class TestSuperUserLogs(ApiTestCase): assert len(json['logs']) > 0 -class TestSuperUserCreateInitialSuperUser(ApiTestCase): - def test_create_superuser(self): - data = { - 'username': 'newsuper', - 'password': 'password', - 'email': 'jschorr+fake@devtable.com', - } - - # Try to write before some config. Should 403. - self.postResponse(SuperUserCreateInitialSuperUser, data=data, expected_code=403) - - # Add some fake config. - fake_config = { - 'AUTHENTICATION_TYPE': 'Database', - 'SECRET_KEY': 'fakekey', - } - - self.putJsonResponse(SuperUserConfig, data=dict(config=fake_config, hostname='fakehost')) - - # Try to write with config. Should 403 since there are users in the DB. - self.postResponse(SuperUserCreateInitialSuperUser, data=data, expected_code=403) - - # Delete all users in the DB. - for user in list(database.User.select()): - model.user.delete_user(user, all_queues) - - # Create the superuser. - self.postJsonResponse(SuperUserCreateInitialSuperUser, data=data) - - # Ensure the user exists in the DB. - self.assertIsNotNone(model.user.get_user('newsuper')) - - # Ensure that the current user is newsuper. - json = self.getJsonResponse(User) - self.assertEquals('newsuper', json['username']) - - # Ensure that the current user is a superuser in the config. - json = self.getJsonResponse(SuperUserConfig) - self.assertEquals(['newsuper'], json['config']['SUPER_USERS']) - - # Ensure that the current user is a superuser in memory by trying to call an API - # that will fail otherwise. - self.getResponse(SuperUserConfigFile, params=dict(filename='ssl.cert')) - - -class TestSuperUserConfig(ApiTestCase): - def test_get_status_update_config(self): - # With no config the status should be 'config-db'. - json = self.getJsonResponse(SuperUserRegistryStatus) - self.assertEquals('config-db', json['status']) - - # And the config should 401. - self.getResponse(SuperUserConfig, expected_code=401) - - # Add some fake config. - fake_config = { - 'AUTHENTICATION_TYPE': 'Database', - 'SECRET_KEY': 'fakekey', - } - - json = self.putJsonResponse(SuperUserConfig, data=dict(config=fake_config, - hostname='fakehost')) - self.assertEquals('fakekey', json['config']['SECRET_KEY']) - self.assertEquals('fakehost', json['config']['SERVER_HOSTNAME']) - self.assertEquals('Database', json['config']['AUTHENTICATION_TYPE']) - - # With config the status should be 'setup-db'. - json = self.getJsonResponse(SuperUserRegistryStatus) - self.assertEquals('setup-db', json['status']) - - def test_config_file(self): - # Try without an account. Should 403. - self.getResponse(SuperUserConfigFile, params=dict(filename='ssl.cert'), expected_code=403) - - # Login to a superuser. - self.login(ADMIN_ACCESS_USER) - - # Try for an invalid file. Should 404. - self.getResponse(SuperUserConfigFile, params=dict(filename='foobar'), expected_code=404) - - # Try for a valid filename. Should not exist. - json = self.getJsonResponse(SuperUserConfigFile, params=dict(filename='ssl.cert')) - self.assertFalse(json['exists']) - - # Add the file. - self.postResponse(SuperUserConfigFile, params=dict(filename='ssl.cert'), - file=(StringIO('my file contents'), 'ssl.cert')) - - # Should now exist. - json = self.getJsonResponse(SuperUserConfigFile, params=dict(filename='ssl.cert')) - self.assertTrue(json['exists']) - - def test_update_with_external_auth(self): - self.login(ADMIN_ACCESS_USER) - - # Run a mock LDAP. - mockldap = MockLdap({ - 'dc=quay,dc=io': { - 'dc': ['quay', 'io'] - }, - 'ou=employees,dc=quay,dc=io': { - 'dc': ['quay', 'io'], - 'ou': 'employees' - }, - 'uid=' + ADMIN_ACCESS_USER + ',ou=employees,dc=quay,dc=io': { - 'dc': ['quay', 'io'], - 'ou': 'employees', - 'uid': [ADMIN_ACCESS_USER], - 'userPassword': ['password'], - 'mail': [ADMIN_ACCESS_EMAIL], - }, - }) - - config = { - 'AUTHENTICATION_TYPE': 'LDAP', - 'LDAP_BASE_DN': ['dc=quay', 'dc=io'], - 'LDAP_ADMIN_DN': 'uid=devtable,ou=employees,dc=quay,dc=io', - 'LDAP_ADMIN_PASSWD': 'password', - 'LDAP_USER_RDN': ['ou=employees'], - 'LDAP_UID_ATTR': 'uid', - 'LDAP_EMAIL_ATTR': 'mail', - } - - mockldap.start() - try: - # Try writing some config with an invalid password. - self.putResponse(SuperUserConfig, data={'config': config, - 'hostname': 'foo'}, expected_code=400) - self.putResponse(SuperUserConfig, - data={'config': config, - 'password': 'invalid', - 'hostname': 'foo'}, expected_code=400) - - # Write the config with the valid password. - self.putResponse(SuperUserConfig, - data={'config': config, - 'password': 'password', - 'hostname': 'foo'}, expected_code=200) - - # Ensure that the user row has been linked. - self.assertEquals(ADMIN_ACCESS_USER, - model.user.verify_federated_login('ldap', ADMIN_ACCESS_USER).username) - finally: - mockldap.stop() - - -class TestSuperUserCustomCertificates(ApiTestCase): - def test_custom_certificates(self): - self.login(ADMIN_ACCESS_USER) - - # Upload a certificate. - cert_contents, _ = generate_test_cert(hostname='somecoolhost', san_list=['DNS:bar', 'DNS:baz']) - self.postResponse(SuperUserCustomCertificate, params=dict(certpath='testcert.crt'), - file=(StringIO(cert_contents), 'testcert.crt'), expected_code=204) - - # Make sure it is present. - json = self.getJsonResponse(SuperUserCustomCertificates) - self.assertEquals(1, len(json['certs'])) - - cert_info = json['certs'][0] - self.assertEquals('testcert.crt', cert_info['path']) - - self.assertEquals(set(['somecoolhost', 'bar', 'baz']), set(cert_info['names'])) - self.assertFalse(cert_info['expired']) - - # Remove the certificate. - self.deleteResponse(SuperUserCustomCertificate, params=dict(certpath='testcert.crt')) - - # Make sure it is gone. - json = self.getJsonResponse(SuperUserCustomCertificates) - self.assertEquals(0, len(json['certs'])) - - def test_expired_custom_certificate(self): - self.login(ADMIN_ACCESS_USER) - - # Upload a certificate. - cert_contents, _ = generate_test_cert(hostname='somecoolhost', expires=-10) - self.postResponse(SuperUserCustomCertificate, params=dict(certpath='testcert.crt'), - file=(StringIO(cert_contents), 'testcert.crt'), expected_code=204) - - # Make sure it is present. - json = self.getJsonResponse(SuperUserCustomCertificates) - self.assertEquals(1, len(json['certs'])) - - cert_info = json['certs'][0] - self.assertEquals('testcert.crt', cert_info['path']) - - self.assertEquals(set(['somecoolhost']), set(cert_info['names'])) - self.assertTrue(cert_info['expired']) - - def test_invalid_custom_certificate(self): - self.login(ADMIN_ACCESS_USER) - - # Upload an invalid certificate. - self.postResponse(SuperUserCustomCertificate, params=dict(certpath='testcert.crt'), - file=(StringIO('some contents'), 'testcert.crt'), expected_code=204) - - # Make sure it is present but invalid. - json = self.getJsonResponse(SuperUserCustomCertificates) - self.assertEquals(1, len(json['certs'])) - - cert_info = json['certs'][0] - self.assertEquals('testcert.crt', cert_info['path']) - self.assertEquals('no start line', cert_info['error']) - - def test_path_sanitization(self): - self.login(ADMIN_ACCESS_USER) - - # Upload a certificate. - cert_contents, _ = generate_test_cert(hostname='somecoolhost', expires=-10) - self.postResponse(SuperUserCustomCertificate, params=dict(certpath='testcert/../foobar.crt'), - file=(StringIO(cert_contents), 'testcert/../foobar.crt'), expected_code=204) - - # Make sure it is present. - json = self.getJsonResponse(SuperUserCustomCertificates) - self.assertEquals(1, len(json['certs'])) - - cert_info = json['certs'][0] - self.assertEquals('foobar.crt', cert_info['path']) - - class TestSuperUserTakeOwnership(ApiTestCase): def test_take_ownership_superuser(self): self.login(ADMIN_ACCESS_USER) diff --git a/test/test_suconfig_api.py b/test/test_suconfig_api.py index 6ae87fa23..1ec5c6267 100644 --- a/test/test_suconfig_api.py +++ b/test/test_suconfig_api.py @@ -1,9 +1,6 @@ -from test.test_api_usage import ApiTestCase, READ_ACCESS_USER, ADMIN_ACCESS_USER -from endpoints.api.suconfig import (SuperUserRegistryStatus, SuperUserConfig, SuperUserConfigFile, - SuperUserCreateInitialSuperUser, SuperUserConfigValidate) -from app import config_provider, all_queues -from data.database import User -from data import model +from test.test_api_usage import ApiTestCase +from endpoints.api.suconfig import SuperUserRegistryStatus +from app import config_provider import unittest @@ -24,169 +21,5 @@ class TestSuperUserRegistryStatus(ApiTestCase): self.assertEquals('config-db', json['status']) -class TestSuperUserConfigFile(ApiTestCase): - def test_get_non_superuser(self): - with FreshConfigProvider(): - # No user. - self.getResponse(SuperUserConfigFile, params=dict(filename='ssl.cert'), expected_code=403) - - # Non-superuser. - self.login(READ_ACCESS_USER) - self.getResponse(SuperUserConfigFile, params=dict(filename='ssl.cert'), expected_code=403) - - def test_get_superuser_invalid_filename(self): - with FreshConfigProvider(): - self.login(ADMIN_ACCESS_USER) - self.getResponse(SuperUserConfigFile, params=dict(filename='somefile'), expected_code=404) - - def test_get_superuser(self): - with FreshConfigProvider(): - self.login(ADMIN_ACCESS_USER) - result = self.getJsonResponse(SuperUserConfigFile, params=dict(filename='ssl.cert')) - self.assertFalse(result['exists']) - - def test_post_non_superuser(self): - with FreshConfigProvider(): - # No user, before config.yaml exists. - self.postResponse(SuperUserConfigFile, params=dict(filename='ssl.cert'), expected_code=400) - - # Write some config. - self.putJsonResponse(SuperUserConfig, data=dict(config={}, hostname='foobar')) - - # No user, with config.yaml. - self.postResponse(SuperUserConfigFile, params=dict(filename='ssl.cert'), expected_code=403) - - # Non-superuser. - self.login(READ_ACCESS_USER) - self.postResponse(SuperUserConfigFile, params=dict(filename='ssl.cert'), expected_code=403) - - def test_post_superuser_invalid_filename(self): - with FreshConfigProvider(): - self.login(ADMIN_ACCESS_USER) - self.postResponse(SuperUserConfigFile, params=dict(filename='somefile'), expected_code=404) - - def test_post_superuser(self): - with FreshConfigProvider(): - self.login(ADMIN_ACCESS_USER) - self.postResponse(SuperUserConfigFile, params=dict(filename='ssl.cert'), expected_code=400) - - -class TestSuperUserCreateInitialSuperUser(ApiTestCase): - def test_no_config_file(self): - with FreshConfigProvider(): - # If there is no config.yaml, then this method should security fail. - data = dict(username='cooluser', password='password', email='fake@example.com') - self.postResponse(SuperUserCreateInitialSuperUser, data=data, expected_code=403) - - def test_config_file_with_db_users(self): - with FreshConfigProvider(): - # Write some config. - self.putJsonResponse(SuperUserConfig, data=dict(config={}, hostname='foobar')) - - # If there is a config.yaml, but existing DB users exist, then this method should security - # fail. - data = dict(username='cooluser', password='password', email='fake@example.com') - self.postResponse(SuperUserCreateInitialSuperUser, data=data, expected_code=403) - - def test_config_file_with_no_db_users(self): - with FreshConfigProvider(): - # Write some config. - self.putJsonResponse(SuperUserConfig, data=dict(config={}, hostname='foobar')) - - # Delete all the users in the DB. - for user in list(User.select()): - model.user.delete_user(user, all_queues) - - # This method should now succeed. - data = dict(username='cooluser', password='password', email='fake@example.com') - result = self.postJsonResponse(SuperUserCreateInitialSuperUser, data=data) - self.assertTrue(result['status']) - - # Verify the superuser was created. - User.get(User.username == 'cooluser') - - # Verify the superuser was placed into the config. - result = self.getJsonResponse(SuperUserConfig) - self.assertEquals(['cooluser'], result['config']['SUPER_USERS']) - - -class TestSuperUserConfigValidate(ApiTestCase): - def test_nonsuperuser_noconfig(self): - with FreshConfigProvider(): - self.login(ADMIN_ACCESS_USER) - result = self.postJsonResponse(SuperUserConfigValidate, params=dict(service='someservice'), - data=dict(config={})) - - self.assertFalse(result['status']) - - - def test_nonsuperuser_config(self): - with FreshConfigProvider(): - # The validate config call works if there is no config.yaml OR the user is a superuser. - # Add a config, and verify it breaks when unauthenticated. - json = self.putJsonResponse(SuperUserConfig, data=dict(config={}, hostname='foobar')) - self.assertTrue(json['exists']) - - self.postResponse(SuperUserConfigValidate, params=dict(service='someservice'), - data=dict(config={}), - expected_code=403) - - # Now login as a superuser. - self.login(ADMIN_ACCESS_USER) - result = self.postJsonResponse(SuperUserConfigValidate, params=dict(service='someservice'), - data=dict(config={})) - - self.assertFalse(result['status']) - - -class TestSuperUserConfig(ApiTestCase): - def test_get_non_superuser(self): - with FreshConfigProvider(): - # No user. - self.getResponse(SuperUserConfig, expected_code=401) - - # Non-superuser. - self.login(READ_ACCESS_USER) - self.getResponse(SuperUserConfig, expected_code=403) - - def test_get_superuser(self): - with FreshConfigProvider(): - self.login(ADMIN_ACCESS_USER) - json = self.getJsonResponse(SuperUserConfig) - - # Note: We expect the config to be none because a config.yaml should never be checked into - # the directory. - self.assertIsNone(json['config']) - - def test_put(self): - with FreshConfigProvider() as config: - # The update config call works if there is no config.yaml OR the user is a superuser. First - # try writing it without a superuser present. - json = self.putJsonResponse(SuperUserConfig, data=dict(config={}, hostname='foobar')) - self.assertTrue(json['exists']) - - # Verify the config file exists. - self.assertTrue(config.config_exists()) - - # Try writing it again. This should now fail, since the config.yaml exists. - self.putResponse(SuperUserConfig, data=dict(config={}, hostname='barbaz'), expected_code=403) - - # Login as a non-superuser. - self.login(READ_ACCESS_USER) - - # Try writing it again. This should fail. - self.putResponse(SuperUserConfig, data=dict(config={}, hostname='barbaz'), expected_code=403) - - # Login as a superuser. - self.login(ADMIN_ACCESS_USER) - - # This should succeed. - json = self.putJsonResponse(SuperUserConfig, data=dict(config={}, hostname='barbaz')) - self.assertTrue(json['exists']) - - json = self.getJsonResponse(SuperUserConfig) - self.assertIsNotNone(json['config']) - - if __name__ == '__main__': unittest.main() diff --git a/util/config/provider/k8sprovider.py b/util/config/provider/k8sprovider.py index 835a85b4d..f0cef9c23 100644 --- a/util/config/provider/k8sprovider.py +++ b/util/config/provider/k8sprovider.py @@ -50,6 +50,10 @@ class KubernetesConfigProvider(BaseFileProvider): # in Kubernetes secrets. return "_".join([directory.rstrip('/'), filename]) + def volume_exists(self): + secret = self._lookup_secret() + return secret is not None + def volume_file_exists(self, relative_file_path): if '/' in relative_file_path: raise Exception('Expected path from get_volume_path, but found slashes')