diff --git a/app.py b/app.py index a94b8d061..c3b15d7aa 100644 --- a/app.py +++ b/app.py @@ -1,9 +1,8 @@ import logging import os import json -import yaml -from flask import Flask as BaseFlask, Config as BaseConfig, request, Request, _request_ctx_stack +from flask import Flask, Config, request, Request, _request_ctx_stack from flask.ext.principal import Principal from flask.ext.login import LoginManager, UserMixin from flask.ext.mail import Mail @@ -12,6 +11,9 @@ import features from avatars.avatars import Avatar from storage import Storage + +from avatars.avatars import Avatar + from data import model from data import database from data.userfiles import Userfiles @@ -27,33 +29,9 @@ from util.names import urn_generator from util.oauth import GoogleOAuthConfig, GithubOAuthConfig from util.signing import Signer from util.queuemetrics import QueueMetrics - - -# pylint: disable=invalid-name,too-many-public-methods,too-few-public-methods,too-many-ancestors -class Config(BaseConfig): - """ Flask config enhanced with a `from_yamlfile` method """ - - def from_yamlfile(self, config_file): - with open(config_file) as f: - c = yaml.load(f) - if not c: - logger.debug('Empty YAML config file') - return - - if isinstance(c, str): - raise Exception('Invalid YAML config file: ' + str(c)) - - for key in c.iterkeys(): - if key.isupper(): - self[key] = c[key] - -class Flask(BaseFlask): - """ Extends the Flask class to implement our custom Config class. """ - - def make_config(self, instance_relative=False): - root_path = self.instance_path if instance_relative else self.root_path - return Config(root_path, self.default_config) - +from util.config.provider import FileConfigProvider, TestConfigProvider +from util.config.configutil import generate_secret_key +from util.config.superusermanager import SuperUserManager OVERRIDE_CONFIG_DIRECTORY = 'conf/stack/' OVERRIDE_CONFIG_YAML_FILENAME = 'conf/stack/config.yaml' @@ -62,12 +40,15 @@ OVERRIDE_CONFIG_PY_FILENAME = 'conf/stack/config.py' OVERRIDE_CONFIG_KEY = 'QUAY_OVERRIDE_CONFIG' LICENSE_FILENAME = 'conf/stack/license.enc' +CONFIG_PROVIDER = FileConfigProvider(OVERRIDE_CONFIG_DIRECTORY, 'config.yaml', 'config.py') app = Flask(__name__) logger = logging.getLogger(__name__) - +# Instantiate the default configuration (for test or for normal operation). if 'TEST' in os.environ: + CONFIG_PROVIDER = TestConfigProvider() + from test.testconfig import TestConfig logger.debug('Loading test config.') app.config.from_object(TestConfig()) @@ -75,20 +56,17 @@ else: from config import DefaultConfig logger.debug('Loading default config.') app.config.from_object(DefaultConfig()) - - if os.path.exists(OVERRIDE_CONFIG_PY_FILENAME): - logger.debug('Applying config file: %s', OVERRIDE_CONFIG_PY_FILENAME) - app.config.from_pyfile(OVERRIDE_CONFIG_PY_FILENAME) - - if os.path.exists(OVERRIDE_CONFIG_YAML_FILENAME): - logger.debug('Applying config file: %s', OVERRIDE_CONFIG_YAML_FILENAME) - app.config.from_yamlfile(OVERRIDE_CONFIG_YAML_FILENAME) - - environ_config = json.loads(os.environ.get(OVERRIDE_CONFIG_KEY, '{}')) - app.config.update(environ_config) - app.teardown_request(database.close_db_filter) +# Load the override config via the provider. +CONFIG_PROVIDER.update_app_config(app.config) + +# Update any configuration found in the override environment variable. +OVERRIDE_CONFIG_KEY = 'QUAY_OVERRIDE_CONFIG' + +environ_config = json.loads(os.environ.get(OVERRIDE_CONFIG_KEY, '{}')) +app.config.update(environ_config) + class RequestWithId(Request): request_gen = staticmethod(urn_generator(['request'])) @@ -137,14 +115,15 @@ sentry = Sentry(app) build_logs = BuildLogs(app) authentication = UserAuthentication(app) userevents = UserEventsBuilderModule(app) +superusers = SuperUserManager(app) signer = Signer(app, OVERRIDE_CONFIG_DIRECTORY) queue_metrics = QueueMetrics(app) tf = app.config['DB_TRANSACTION_FACTORY'] -github_login = GithubOAuthConfig(app, 'GITHUB_LOGIN_CONFIG') -github_trigger = GithubOAuthConfig(app, 'GITHUB_TRIGGER_CONFIG') -google_login = GoogleOAuthConfig(app, 'GOOGLE_LOGIN_CONFIG') +github_login = GithubOAuthConfig(app.config, 'GITHUB_LOGIN_CONFIG') +github_trigger = GithubOAuthConfig(app.config, 'GITHUB_TRIGGER_CONFIG') +google_login = GoogleOAuthConfig(app.config, 'GOOGLE_LOGIN_CONFIG') oauth_apps = [github_login, github_trigger, google_login] image_diff_queue = WorkQueue(app.config['DIFFS_QUEUE_NAME'], tf) @@ -156,6 +135,11 @@ database.configure(app.config) model.config.app_config = app.config model.config.store = storage +# Generate a secret key if none was specified. +if app.config['SECRET_KEY'] is None: + logger.debug('Generating in-memory secret key') + app.config['SECRET_KEY'] = generate_secret_key() + @login_manager.user_loader def load_user(user_uuid): logger.debug('User loader loading deferred user with uuid: %s' % user_uuid) diff --git a/auth/permissions.py b/auth/permissions.py index f970450af..4ee73bdb3 100644 --- a/auth/permissions.py +++ b/auth/permissions.py @@ -7,7 +7,7 @@ from functools import partial import scopes from data import model -from app import app +from app import app, superusers logger = logging.getLogger(__name__) @@ -92,9 +92,11 @@ class QuayDeferredPermissionUser(Identity): if user_object is None: return super(QuayDeferredPermissionUser, self).can(permission) + if user_object is None: + return super(QuayDeferredPermissionUser, self).can(permission) + # Add the superuser need, if applicable. - if (user_object.username is not None and - user_object.username in app.config.get('SUPER_USERS', [])): + if superusers.is_superuser(user_object.username): self.provides.add(_SuperUserNeed()) # Add the user specific permissions, only for non-oauth permission diff --git a/buildman/builder.py b/buildman/builder.py index 9fdca3da1..2a0225751 100644 --- a/buildman/builder.py +++ b/buildman/builder.py @@ -28,6 +28,8 @@ LOG_FORMAT = "%(asctime)s [%(process)d] [%(levelname)s] [%(name)s] %(message)s" def run_build_manager(): if not features.BUILD_SUPPORT: logger.debug('Building is disabled. Please enable the feature flag') + while True: + time.sleep(1000) return build_manager_config = app.config.get('BUILD_MANAGER') diff --git a/buildman/component/buildcomponent.py b/buildman/component/buildcomponent.py index c1fb41a02..72caec215 100644 --- a/buildman/component/buildcomponent.py +++ b/buildman/component/buildcomponent.py @@ -10,7 +10,6 @@ from autobahn.wamp.exception import ApplicationError from buildman.server import BuildJobResult from buildman.component.basecomponent import BaseComponent from buildman.jobutil.buildjob import BuildJobLoadException -from buildman.jobutil.buildpack import BuildPackage, BuildPackageException from buildman.jobutil.buildstatus import StatusHandler from buildman.jobutil.workererror import WorkerError @@ -20,7 +19,7 @@ HEARTBEAT_DELTA = datetime.timedelta(seconds=30) HEARTBEAT_TIMEOUT = 10 INITIAL_TIMEOUT = 25 -SUPPORTED_WORKER_VERSIONS = ['0.1-beta', '0.2'] +SUPPORTED_WORKER_VERSIONS = ['0.3'] logger = logging.getLogger(__name__) @@ -56,7 +55,10 @@ class BuildComponent(BaseComponent): def onJoin(self, details): logger.debug('Registering methods and listeners for component %s', self.builder_realm) yield trollius.From(self.register(self._on_ready, u'io.quay.buildworker.ready')) + yield trollius.From(self.register(self._determine_cache_tag, + u'io.quay.buildworker.determinecachetag')) yield trollius.From(self.register(self._ping, u'io.quay.buildworker.ping')) + yield trollius.From(self.subscribe(self._on_heartbeat, 'io.quay.builder.heartbeat')) yield trollius.From(self.subscribe(self._on_log_message, 'io.quay.builder.logmessage')) @@ -91,46 +93,6 @@ class BuildComponent(BaseComponent): buildpack_url = self.user_files.get_file_url(build_job.repo_build.resource_key, requires_cors=False) - # TODO(jschorr): Remove as soon as the fleet has been transitioned to 0.2. - if self._worker_version == '0.1-beta': - # Retrieve the job's buildpack. - logger.debug('Retrieving build package: %s', buildpack_url) - buildpack = None - try: - buildpack = BuildPackage.from_url(buildpack_url) - except BuildPackageException as bpe: - self._build_failure('Could not retrieve build package', bpe) - raise trollius.Return() - - # Extract the base image information from the Dockerfile. - parsed_dockerfile = None - logger.debug('Parsing dockerfile') - - try: - parsed_dockerfile = buildpack.parse_dockerfile(build_config.get('build_subdir')) - except BuildPackageException as bpe: - self._build_failure('Could not find Dockerfile in build package', bpe) - raise trollius.Return() - - image_and_tag_tuple = parsed_dockerfile.get_image_and_tag() - if image_and_tag_tuple is None or image_and_tag_tuple[0] is None: - self._build_failure('Missing FROM line in Dockerfile') - raise trollius.Return() - - base_image_information = { - 'repository': image_and_tag_tuple[0], - 'tag': image_and_tag_tuple[1] - } - - # Extract the number of steps from the Dockerfile. - with self._build_status as status_dict: - status_dict['total_commands'] = len(parsed_dockerfile.commands) - else: - # TODO(jschorr): This is a HACK to make sure the progress bar (sort of) continues working - # until such time as we have the caching code in place. - with self._build_status as status_dict: - status_dict['total_commands'] = 25 - # Add the pull robot information, if any. if build_job.pull_credentials: base_image_information['username'] = build_job.pull_credentials.get('username', '') @@ -161,8 +123,7 @@ class BuildComponent(BaseComponent): 'pull_token': build_job.repo_build.access_token.code, 'push_token': build_job.repo_build.access_token.code, 'tag_names': build_config.get('docker_tags', ['latest']), - 'base_image': base_image_information, - 'cached_tag': build_job.determine_cached_tag() or '' + 'base_image': base_image_information } # Invoke the build. @@ -256,6 +217,16 @@ class BuildComponent(BaseComponent): elif phase == BUILD_PHASE.BUILDING: self._build_status.append_log(current_status_string) + @trollius.coroutine + def _determine_cache_tag(self, command_comments, base_image_name, base_image_tag, base_image_id): + with self._build_status as status_dict: + status_dict['total_commands'] = len(command_comments) + 1 + + logger.debug('Checking cache on realm %s. Base image: %s:%s (%s)', self.builder_realm, + base_image_name, base_image_tag, base_image_id) + + tag_found = self._current_job.determine_cached_tag(base_image_id, command_comments) + raise trollius.Return(tag_found or '') def _build_failure(self, error_message, exception=None): """ Handles and logs a failed build. """ diff --git a/buildman/jobutil/buildjob.py b/buildman/jobutil/buildjob.py index bb750fc38..1710c3aab 100644 --- a/buildman/jobutil/buildjob.py +++ b/buildman/jobutil/buildjob.py @@ -1,8 +1,12 @@ import json +import logging from cachetools import lru_cache from endpoints.notificationhelper import spawn_notification from data import model +from util.imagetree import ImageTree + +logger = logging.getLogger(__name__) class BuildJobLoadException(Exception): @@ -65,14 +69,65 @@ class BuildJob(object): 'Could not parse repository build job config with ID %s' % self.job_details['build_uuid'] ) - def determine_cached_tag(self): + def determine_cached_tag(self, base_image_id=None, cache_comments=None): """ Returns the tag to pull to prime the cache or None if none. """ - # TODO(jschorr): Change this to use the more complicated caching rules, once we have caching - # be a pull of things besides the constructed tags. - tags = self.build_config.get('docker_tags', ['latest']) - existing_tags = model.list_repository_tags(self.repo_build.repository.namespace_user.username, - self.repo_build.repository.name) + cached_tag = None + if base_image_id and cache_comments: + cached_tag = self._determine_cached_tag_by_comments(base_image_id, cache_comments) + if not cached_tag: + cached_tag = self._determine_cached_tag_by_tag() + + logger.debug('Determined cached tag %s for %s: %s', cached_tag, base_image_id, cache_comments) + + return cached_tag + + def _determine_cached_tag_by_comments(self, base_image_id, cache_commands): + """ Determines the tag to use for priming the cache for this build job, by matching commands + starting at the given base_image_id. This mimics the Docker cache checking, so it should, + in theory, provide "perfect" caching. + """ + # Lookup the base image in the repository. If it doesn't exist, nothing more to do. + repo_build = self.repo_build + repo_namespace = repo_build.repository.namespace_user.username + repo_name = repo_build.repository.name + + base_image = model.get_image(repo_build.repository, base_image_id) + if base_image is None: + return None + + # Build an in-memory tree of the full heirarchy of images in the repository. + all_images = model.get_repository_images(repo_namespace, repo_name) + all_tags = model.list_repository_tags(repo_namespace, repo_name) + tree = ImageTree(all_images, all_tags, base_filter=base_image.id) + + # Find a path in the tree, starting at the base image, that matches the cache comments + # or some subset thereof. + def checker(step, image): + if step >= len(cache_commands): + return False + + full_command = '["/bin/sh", "-c", "%s"]' % cache_commands[step] + logger.debug('Checking step #%s: %s, %s == %s', step, image.id, + image.storage.command, full_command) + + return image.storage.command == full_command + + path = tree.find_longest_path(base_image.id, checker) + if not path: + return None + + # Find any tag associated with the last image in the path. + return tree.tag_containing_image(path[-1]) + + + def _determine_cached_tag_by_tag(self): + """ Determines the cached tag by looking for one of the tags being built, and seeing if it + exists in the repository. This is a fallback for when no comment information is available. + """ + tags = self.build_config.get('docker_tags', ['latest']) + repository = self.repo_build.repository + existing_tags = model.list_repository_tags(repository.namespace_user.username, repository.name) cached_tags = set(tags) & set([tag.name for tag in existing_tags]) if cached_tags: return list(cached_tags)[0] diff --git a/buildman/jobutil/buildpack.py b/buildman/jobutil/buildpack.py deleted file mode 100644 index 9892c65d3..000000000 --- a/buildman/jobutil/buildpack.py +++ /dev/null @@ -1,88 +0,0 @@ -import tarfile -import requests -import os - -from tempfile import TemporaryFile, mkdtemp -from zipfile import ZipFile -from util.dockerfileparse import parse_dockerfile -from util.safetar import safe_extractall - -class BuildPackageException(Exception): - """ Exception raised when retrieving or parsing a build package. """ - pass - - -class BuildPackage(object): - """ Helper class for easy reading and updating of a Dockerfile build pack. """ - - def __init__(self, requests_file): - self._mime_processors = { - 'application/zip': BuildPackage._prepare_zip, - 'application/x-zip-compressed': BuildPackage._prepare_zip, - 'text/plain': BuildPackage._prepare_dockerfile, - 'application/octet-stream': BuildPackage._prepare_dockerfile, - 'application/x-tar': BuildPackage._prepare_tarball, - 'application/gzip': BuildPackage._prepare_tarball, - 'application/x-gzip': BuildPackage._prepare_tarball, - } - - c_type = requests_file.headers['content-type'] - c_type = c_type.split(';')[0] if ';' in c_type else c_type - - if c_type not in self._mime_processors: - raise BuildPackageException('Unknown build package mime type: %s' % c_type) - - self._package_directory = None - try: - self._package_directory = self._mime_processors[c_type](requests_file) - except Exception as ex: - raise BuildPackageException(ex.message) - - def parse_dockerfile(self, subdirectory): - dockerfile_path = os.path.join(self._package_directory, subdirectory, 'Dockerfile') - if not os.path.exists(dockerfile_path): - if subdirectory: - message = 'Build package did not contain a Dockerfile at sub directory %s.' % subdirectory - else: - message = 'Build package did not contain a Dockerfile at the root directory.' - - raise BuildPackageException(message) - - with open(dockerfile_path, 'r') as dockerfileobj: - return parse_dockerfile(dockerfileobj.read()) - - @staticmethod - def from_url(url): - buildpack_resource = requests.get(url, stream=True) - return BuildPackage(buildpack_resource) - - @staticmethod - def _prepare_zip(request_file): - build_dir = mkdtemp(prefix='docker-build-') - - # Save the zip file to temp somewhere - with TemporaryFile() as zip_file: - zip_file.write(request_file.content) - to_extract = ZipFile(zip_file) - to_extract.extractall(build_dir) - - return build_dir - - @staticmethod - def _prepare_dockerfile(request_file): - build_dir = mkdtemp(prefix='docker-build-') - dockerfile_path = os.path.join(build_dir, "Dockerfile") - with open(dockerfile_path, 'w') as dockerfile: - dockerfile.write(request_file.content) - - return build_dir - - @staticmethod - def _prepare_tarball(request_file): - build_dir = mkdtemp(prefix='docker-build-') - - # Save the zip file to temp somewhere - with tarfile.open(mode='r|*', fileobj=request_file.raw) as tar_stream: - safe_extractall(tar_stream, build_dir) - - return build_dir diff --git a/buildman/jobutil/buildstatus.py b/buildman/jobutil/buildstatus.py index 217e3aa6c..2ae127ee0 100644 --- a/buildman/jobutil/buildstatus.py +++ b/buildman/jobutil/buildstatus.py @@ -7,11 +7,12 @@ class StatusHandler(object): def __init__(self, build_logs, repository_build_uuid): self._current_phase = None + self._current_command = None self._uuid = repository_build_uuid self._build_logs = build_logs self._status = { - 'total_commands': None, + 'total_commands': 0, 'current_command': None, 'push_completion': 0.0, 'pull_completion': 0.0, @@ -26,9 +27,16 @@ class StatusHandler(object): self._build_logs.append_log_message(self._uuid, log_message, log_type, log_data) def append_log(self, log_message, extra_data=None): + if log_message is None: + return + self._append_log_message(log_message, log_data=extra_data) def set_command(self, command, extra_data=None): + if self._current_command == command: + return + + self._current_command = command self._append_log_message(command, self._build_logs.COMMAND, extra_data) def set_error(self, error_message, extra_data=None, internal_error=False): diff --git a/buildman/jobutil/workererror.py b/buildman/jobutil/workererror.py index c7100360b..fdf6503b0 100644 --- a/buildman/jobutil/workererror.py +++ b/buildman/jobutil/workererror.py @@ -63,6 +63,11 @@ class WorkerError(object): 'io.quay.builder.missingorinvalidargument': { 'message': 'Missing required arguments for builder', 'is_internal': True + }, + + 'io.quay.builder.cachelookupissue': { + 'message': 'Error checking for a cached tag', + 'is_internal': True } } diff --git a/buildman/manager/executor.py b/buildman/manager/executor.py index 92641c6ce..035d5cdf8 100644 --- a/buildman/manager/executor.py +++ b/buildman/manager/executor.py @@ -11,6 +11,7 @@ from trollius import coroutine, From, Return, get_event_loop from functools import partial from buildman.asyncutil import AsyncWrapper +from container_cloud_config import CloudConfigContext logger = logging.getLogger(__name__) @@ -20,7 +21,7 @@ ONE_HOUR = 60*60 ENV = Environment(loader=FileSystemLoader('buildman/templates')) TEMPLATE = ENV.get_template('cloudconfig.yaml') - +CloudConfigContext().populate_jinja_environment(ENV) class ExecutorException(Exception): """ Exception raised when there is a problem starting or stopping a builder. diff --git a/buildman/templates/cloudconfig.yaml b/buildman/templates/cloudconfig.yaml index 13e6894bf..51bb2f090 100644 --- a/buildman/templates/cloudconfig.yaml +++ b/buildman/templates/cloudconfig.yaml @@ -19,18 +19,13 @@ coreos: group: {{ coreos_channel }} units: - - name: quay-builder.service - command: start - content: | - [Unit] - Description=Quay builder container - Author=Jake Moshenko - After=docker.service - - [Service] - TimeoutStartSec=600 - TimeoutStopSec=2000 - ExecStartPre=/usr/bin/docker login -u {{ quay_username }} -p {{ quay_password }} -e unused quay.io - ExecStart=/usr/bin/docker run --rm --net=host --name quay-builder --privileged --env-file /root/overrides.list -v /var/run/docker.sock:/var/run/docker.sock -v /usr/share/ca-certificates:/etc/ssl/certs quay.io/coreos/registry-build-worker:{{ worker_tag }} - ExecStop=/usr/bin/docker stop quay-builder - ExecStopPost=/bin/sh -xc "/bin/sleep 120; /usr/bin/systemctl --no-block poweroff" + {{ dockersystemd('quay-builder', + 'quay.io/coreos/registry-build-worker', + quay_username, + quay_password, + worker_tag, + extra_args='--net=host --privileged --env-file /root/overrides.list -v /var/run/docker.sock:/var/run/docker.sock -v /usr/share/ca-certificates:/etc/ssl/certs', + exec_stop_post=['/bin/sh -xc "/bin/sleep 120; /usr/bin/systemctl --no-block poweroff"'], + flattened=True, + restart_policy='no' + ) | indent(4) }} diff --git a/conf/gunicorn_local.py b/conf/gunicorn_local.py index ce93304b0..6987041be 100644 --- a/conf/gunicorn_local.py +++ b/conf/gunicorn_local.py @@ -5,3 +5,4 @@ timeout = 2000 daemon = False logconfig = 'conf/logging_debug.conf' pythonpath = '.' +preload_app = True diff --git a/conf/proxy-server-base.conf b/conf/proxy-server-base.conf index 5bee725cf..fb2f3f962 100644 --- a/conf/proxy-server-base.conf +++ b/conf/proxy-server-base.conf @@ -13,7 +13,6 @@ proxy_set_header X-Forwarded-For $proxy_protocol_addr; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header Host $http_host; proxy_redirect off; -proxy_buffer_size 6m; proxy_set_header Transfer-Encoding $http_transfer_encoding; diff --git a/conf/server-base.conf b/conf/server-base.conf index 6ac4dfb28..d5b211c52 100644 --- a/conf/server-base.conf +++ b/conf/server-base.conf @@ -16,7 +16,6 @@ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header Host $http_host; proxy_redirect off; -proxy_buffer_size 6m; proxy_set_header Transfer-Encoding $http_transfer_encoding; diff --git a/config.py b/config.py index 39a257b15..e6f06a60e 100644 --- a/config.py +++ b/config.py @@ -36,7 +36,6 @@ def getFrontendVisibleConfig(config_dict): class DefaultConfig(object): # Flask config - SECRET_KEY = 'a36c9d7d-25a9-4d3f-a586-3d2f8dc40a83' JSONIFY_PRETTYPRINT_REGULAR = False SESSION_COOKIE_SECURE = False @@ -48,8 +47,9 @@ class DefaultConfig(object): AVATAR_KIND = 'local' - REGISTRY_TITLE = 'Quay.io' - REGISTRY_TITLE_SHORT = 'Quay.io' + REGISTRY_TITLE = 'CoreOS Enterprise Registry' + REGISTRY_TITLE_SHORT = 'Enterprise Registry' + CONTACT_INFO = [ 'mailto:support@quay.io', 'irc://chat.freenode.net:6665/quayio', @@ -132,6 +132,9 @@ class DefaultConfig(object): # Super user config. Note: This MUST BE an empty list for the default config. SUPER_USERS = [] + # Feature Flag: Whether super users are supported. + FEATURE_SUPER_USERS = True + # Feature Flag: Whether billing is required. FEATURE_BILLING = False @@ -147,9 +150,6 @@ class DefaultConfig(object): # Feature flag, whether to enable olark chat FEATURE_OLARK_CHAT = False - # Feature Flag: Whether super users are supported. - FEATURE_SUPER_USERS = False - # Feature Flag: Whether to support GitHub build triggers. FEATURE_GITHUB_BUILD = False @@ -187,3 +187,11 @@ class DefaultConfig(object): # For enterprise: MAXIMUM_REPOSITORY_USAGE = 20 + + # System logs. + SYSTEM_LOGS_PATH = "/var/log/" + SYSTEM_SERVICE_LOGS_PATH = "/var/log/%s/current" + SYSTEM_SERVICES_PATH = "conf/init/" + + # Services that should not be shown in the logs view. + SYSTEM_SERVICE_BLACKLIST = ['tutumdocker', 'dockerfilebuild'] \ No newline at end of file diff --git a/data/migrations/env.py b/data/migrations/env.py index 3b2df5186..108c4c496 100644 --- a/data/migrations/env.py +++ b/data/migrations/env.py @@ -18,7 +18,8 @@ config.set_main_option('sqlalchemy.url', unquote(app.config['DB_URI'])) # Interpret the config file for Python logging. # This line sets up loggers basically. -fileConfig(config.config_file_name) +if config.config_file_name: + fileConfig(config.config_file_name) # add your model's MetaData object here # for 'autogenerate' support diff --git a/data/model/legacy.py b/data/model/legacy.py index 6a5c254b6..f9ab5ef1d 100644 --- a/data/model/legacy.py +++ b/data/model/legacy.py @@ -1105,6 +1105,26 @@ def get_repository(namespace_name, repository_name): return None +def get_image(repo, dockerfile_id): + try: + return Image.get(Image.docker_image_id == dockerfile_id, Image.repository == repo) + except Image.DoesNotExist: + return None + + +def find_child_image(repo, parent_image, command): + try: + return (Image.select() + .join(ImageStorage) + .switch(Image) + .where(Image.ancestors % '%/' + parent_image.id + '/%', + ImageStorage.command == command) + .order_by(ImageStorage.created.desc()) + .get()) + except Image.DoesNotExist: + return None + + def get_repo_image(namespace_name, repository_name, docker_image_id): def limit_to_image_id(query): return query.where(Image.docker_image_id == docker_image_id).limit(1) @@ -1714,7 +1734,6 @@ def get_tag_image(namespace_name, repository_name, tag_name): else: return images[0] - def get_image_by_id(namespace_name, repository_name, docker_image_id): image = get_repo_image_extended(namespace_name, repository_name, docker_image_id) if not image: diff --git a/data/runmigration.py b/data/runmigration.py new file mode 100644 index 000000000..b06cf861d --- /dev/null +++ b/data/runmigration.py @@ -0,0 +1,20 @@ +import logging + +from alembic.config import Config +from alembic.script import ScriptDirectory +from alembic.environment import EnvironmentContext +from alembic.migration import __name__ as migration_name + +def run_alembic_migration(log_handler=None): + if log_handler: + logging.getLogger(migration_name).addHandler(log_handler) + + config = Config() + config.set_main_option("script_location", "data:migrations") + script = ScriptDirectory.from_config(config) + + def fn(rev, context): + return script._upgrade_revs('head', rev) + + with EnvironmentContext(config, script, fn=fn, destination_rev='head'): + script.run_env() \ No newline at end of file diff --git a/endpoints/api/__init__.py b/endpoints/api/__init__.py index 821a18f05..377834002 100644 --- a/endpoints/api/__init__.py +++ b/endpoints/api/__init__.py @@ -280,6 +280,23 @@ require_user_read = require_user_permission(UserReadPermission, scopes.READ_USER require_user_admin = require_user_permission(UserAdminPermission, None) require_fresh_user_admin = require_user_permission(UserAdminPermission, None) + +def verify_not_prod(func): + @add_method_metadata('enterprise_only', True) + @wraps(func) + def wrapped(*args, **kwargs): + # Verify that we are not running on a production (i.e. hosted) stack. If so, we fail. + # This should never happen (because of the feature-flag on SUPER_USERS), but we want to be + # absolutely sure. + if app.config['SERVER_HOSTNAME'].find('quay.io') >= 0: + logger.error('!!! Super user method called IN PRODUCTION !!!') + raise NotFound() + + return func(*args, **kwargs) + + return wrapped + + def require_fresh_login(func): @add_method_metadata('requires_fresh_login', True) @wraps(func) @@ -385,8 +402,10 @@ import endpoints.api.repoemail import endpoints.api.repotoken import endpoints.api.robot import endpoints.api.search +import endpoints.api.suconfig import endpoints.api.superuser import endpoints.api.tag import endpoints.api.team import endpoints.api.trigger import endpoints.api.user + diff --git a/endpoints/api/suconfig.py b/endpoints/api/suconfig.py new file mode 100644 index 000000000..daaba41ce --- /dev/null +++ b/endpoints/api/suconfig.py @@ -0,0 +1,361 @@ +import logging +import os +import json +import signal + +from flask import abort, Response +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 app import app, CONFIG_PROVIDER, superusers +from data import model +from data.database import configure +from auth.permissions import SuperUserPermission +from auth.auth_context import get_authenticated_user +from data.database import User +from util.config.configutil import add_enterprise_config_defaults +from util.config.validator import validate_service_for_config, SSL_FILENAMES +from data.runmigration import run_alembic_migration + +import features + +logger = logging.getLogger(__name__) + +def database_is_valid(): + """ Returns whether the database, as configured, is valid. """ + if app.config['TESTING']: + return False + + try: + list(User.select().limit(1)) + return True + except: + return False + + +def database_has_users(): + """ Returns whether the database has any users defined. """ + return bool(list(User.select().limit(1))) + + +@resource('/v1/superuser/registrystatus') +@internal_only +@show_if(features.SUPER_USERS) +class SuperUserRegistryStatus(ApiResource): + """ Resource for determining the status of the registry, such as if config exists, + if a database is configured, and if it has any defined users. + """ + @nickname('scRegistryStatus') + @verify_not_prod + def get(self): + """ Returns the status of the registry. """ + # If there is no conf/stack volume, then report that status. + if not CONFIG_PROVIDER.volume_exists(): + return { + 'status': 'missing-config-dir' + } + + # If there is no config file, we need to setup the database. + if not CONFIG_PROVIDER.yaml_exists(): + return { + 'status': 'config-db' + } + + # If the database isn't yet valid, then we need to set it up. + if not database_is_valid(): + return { + 'status': 'setup-db' + } + + # If we have SETUP_COMPLETE, then we're ready to go! + if app.config.get('SETUP_COMPLETE', False): + return { + 'requires_restart': CONFIG_PROVIDER.requires_restart(app.config), + 'status': 'ready' + } + + return { + 'status': 'create-superuser' if not database_has_users() else 'config' + } + + +class _AlembicLogHandler(logging.Handler): + def __init__(self): + super(_AlembicLogHandler, self).__init__() + self.records = [] + + def emit(self, record): + self.records.append({ + 'level': record.levelname, + 'message': record.getMessage() + }) + +@resource('/v1/superuser/setupdb') +@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.yaml_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_yaml()) + + configure(combined) + app.config['DB_URI'] = combined['DB_URI'] + + log_handler = _AlembicLogHandler() + + try: + run_alembic_migration(log_handler) + except Exception as ex: + return { + 'error': str(ex) + } + + return { + 'logs': log_handler.records + } + + abort(403) + + + +@resource('/v1/superuser/shutdown') +@internal_only +@show_if(features.SUPER_USERS) +class SuperUserShutdown(ApiResource): + """ Resource for sending a shutdown signal to the container. """ + + @verify_not_prod + @nickname('scShutdownContainer') + def post(self): + """ Sends a signal to the phusion init system to shut down the container. """ + # 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 app.config['TESTING'] or not database_has_users() or SuperUserPermission().can(): + # Note: We skip if debugging locally. + if app.config.get('DEBUGGING') == True: + return {} + + os.kill(1, signal.SIGINT) + 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': { + 'id': 'UpdateConfig', + 'type': 'object', + 'description': 'Updates the YAML config file', + 'required': [ + 'config', + 'hostname' + ], + 'properties': { + 'config': { + 'type': 'object' + }, + 'hostname': { + '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_yaml() + return { + 'config': config_object + } + + abort(403) + + @nickname('scUpdateConfig') + @verify_not_prod + @validate_json_request('UpdateConfig') + def put(self): + """ Updates the config.yaml 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.yaml_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 YAML file. + CONFIG_PROVIDER.save_yaml(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 filename in SSL_FILENAMES: + 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 filename in SSL_FILENAMES: + abort(404) + + if SuperUserPermission().can(): + uploaded_file = request.files['file'] + if not uploaded_file: + abort(400) + + CONFIG_PROVIDER.save_volume_file(filename, uploaded_file) + 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': { + 'id': '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.yaml_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 = model.create_user(username, password, email, auto_verify=True) + + # Add the user to the config. + config_object = CONFIG_PROVIDER.get_yaml() + config_object['SUPER_USERS'] = [username] + CONFIG_PROVIDER.save_yaml(config_object) + + # Update the in-memory config for the new superuser. + superusers.register_superuser(username) + + # Conduct login with that user. + common_login(superuser) + + 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': { + 'id': 'ValidateConfig', + 'type': 'object', + 'description': 'Validates configuration', + 'required': [ + 'config' + ], + 'properties': { + 'config': { + 'type': 'object' + } + }, + }, + } + + @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.yaml_exists() or SuperUserPermission().can(): + config = request.get_json()['config'] + return validate_service_for_config(service, config) + + abort(403) \ No newline at end of file diff --git a/endpoints/api/superuser.py b/endpoints/api/superuser.py index 753e9caba..a391b3130 100644 --- a/endpoints/api/superuser.py +++ b/endpoints/api/superuser.py @@ -1,15 +1,16 @@ import string import logging import json +import os from random import SystemRandom -from app import app +from app import app, avatar, superusers from flask import request from endpoints.api import (ApiResource, nickname, resource, validate_json_request, request_error, log_action, internal_only, NotFound, require_user_admin, format_date, InvalidToken, require_scope, format_date, hide_if, show_if, parse_args, - query_param, abort, require_fresh_login, path_param) + query_param, abort, require_fresh_login, path_param, verify_not_prod) from endpoints.api.logs import get_logs @@ -22,18 +23,76 @@ import features logger = logging.getLogger(__name__) +def get_immediate_subdirectories(directory): + return [name for name in os.listdir(directory) if os.path.isdir(os.path.join(directory, name))] + +def get_services(): + services = set(get_immediate_subdirectories(app.config['SYSTEM_SERVICES_PATH'])) + services = services - set(app.config['SYSTEM_SERVICE_BLACKLIST']) + return services + + +@resource('/v1/superuser/systemlogs/') +@internal_only +@show_if(features.SUPER_USERS) +class SuperUserGetLogsForService(ApiResource): + """ Resource for fetching the kinds of system logs in the system. """ + @require_fresh_login + @verify_not_prod + @nickname('getSystemLogs') + def get(self, service): + """ Returns the logs for the specific service. """ + if SuperUserPermission().can(): + if not service in get_services(): + abort(404) + + try: + with open(app.config['SYSTEM_SERVICE_LOGS_PATH'] % service, 'r') as f: + logs = f.read() + except Exception as ex: + logger.exception('Cannot read logs') + abort(400) + + return { + 'logs': logs + } + + abort(403) + + +@resource('/v1/superuser/systemlogs/') +@internal_only +@show_if(features.SUPER_USERS) +class SuperUserSystemLogServices(ApiResource): + """ Resource for fetching the kinds of system logs in the system. """ + @require_fresh_login + @verify_not_prod + @nickname('listSystemLogServices') + def get(self): + """ List the system logs for the current system. """ + if SuperUserPermission().can(): + return { + 'services': list(get_services()) + } + + abort(403) + + + @resource('/v1/superuser/logs') @internal_only @show_if(features.SUPER_USERS) class SuperUserLogs(ApiResource): """ Resource for fetching all logs in the system. """ + @require_fresh_login + @verify_not_prod @nickname('listAllLogs') @parse_args @query_param('starttime', 'Earliest time from which to get logs. (%m/%d/%Y %Z)', type=str) @query_param('endtime', 'Latest time to which to get logs. (%m/%d/%Y %Z)', type=str) @query_param('performer', 'Username for which to filter logs.', type=str) def get(self, args): - """ List the logs for the current system. """ + """ List the usage logs for the current system. """ if SuperUserPermission().can(): performer_name = args['performer'] start_time = args['starttime'] @@ -49,7 +108,8 @@ def user_view(user): 'username': user.username, 'email': user.email, 'verified': user.verified, - 'super_user': user.username in app.config['SUPER_USERS'] + 'avatar': avatar.compute_hash(user.email, name=user.username), + 'super_user': superusers.is_superuser(user.username) } @resource('/v1/superuser/usage/') @@ -58,6 +118,7 @@ def user_view(user): class UsageInformation(ApiResource): """ Resource for returning the usage information for enterprise customers. """ @require_fresh_login + @verify_not_prod @nickname('getSystemUsage') def get(self): """ Returns the number of repository handles currently held. """ @@ -96,6 +157,7 @@ class SuperUserList(ApiResource): } @require_fresh_login + @verify_not_prod @nickname('listAllUsers') def get(self): """ Returns a list of all users in the system. """ @@ -109,6 +171,7 @@ class SuperUserList(ApiResource): @require_fresh_login + @verify_not_prod @nickname('createInstallUser') @validate_json_request('CreateInstallUser') def post(self): @@ -146,6 +209,7 @@ class SuperUserList(ApiResource): class SuperUserSendRecoveryEmail(ApiResource): """ Resource for sending a recovery user on behalf of a user. """ @require_fresh_login + @verify_not_prod @nickname('sendInstallUserRecoveryEmail') def post(self, username): if SuperUserPermission().can(): @@ -153,7 +217,7 @@ class SuperUserSendRecoveryEmail(ApiResource): if not user or user.organization or user.robot: abort(404) - if username in app.config['SUPER_USERS']: + if superusers.is_superuser(username): abort(403) code = model.create_reset_password_email_code(user.email) @@ -190,6 +254,7 @@ class SuperUserManagement(ApiResource): } @require_fresh_login + @verify_not_prod @nickname('getInstallUser') def get(self, username): """ Returns information about the specified user. """ @@ -203,6 +268,7 @@ class SuperUserManagement(ApiResource): abort(403) @require_fresh_login + @verify_not_prod @nickname('deleteInstallUser') def delete(self, username): """ Deletes the specified user. """ @@ -211,7 +277,7 @@ class SuperUserManagement(ApiResource): if not user or user.organization or user.robot: abort(404) - if username in app.config['SUPER_USERS']: + if superusers.is_superuser(username): abort(403) model.delete_user(user) @@ -220,6 +286,7 @@ class SuperUserManagement(ApiResource): abort(403) @require_fresh_login + @verify_not_prod @nickname('changeInstallUser') @validate_json_request('UpdateUser') def put(self, username): @@ -229,7 +296,7 @@ class SuperUserManagement(ApiResource): if not user or user.organization or user.robot: abort(404) - if username in app.config['SUPER_USERS']: + if superusers.is_superuser(username): abort(403) user_data = request.get_json() diff --git a/endpoints/csrf.py b/endpoints/csrf.py index b4b40d17c..39a0d636b 100644 --- a/endpoints/csrf.py +++ b/endpoints/csrf.py @@ -19,19 +19,21 @@ def generate_csrf_token(): return session['_csrf_token'] +def verify_csrf(): + token = session.get('_csrf_token', None) + found_token = request.values.get('_csrf_token', None) + + if not token or token != found_token: + msg = 'CSRF Failure. Session token was %s and request token was %s' + logger.error(msg, token, found_token) + abort(403, message='CSRF token was invalid or missing.') def csrf_protect(func): @wraps(func) def wrapper(*args, **kwargs): oauth_token = get_validated_oauth_token() if oauth_token is None and request.method != "GET" and request.method != "HEAD": - token = session.get('_csrf_token', None) - found_token = request.values.get('_csrf_token', None) - - if not token or token != found_token: - msg = 'CSRF Failure. Session token was %s and request token was %s' - logger.error(msg, token, found_token) - abort(403, message='CSRF token was invalid or missing.') + verify_csrf() return func(*args, **kwargs) return wrapper diff --git a/endpoints/web.py b/endpoints/web.py index 00c61b5b8..6123537f0 100644 --- a/endpoints/web.py +++ b/endpoints/web.py @@ -12,15 +12,18 @@ from data import model from data.model.oauth import DatabaseAuthorizationProvider from app import app, billing as stripe, build_logs, avatar, signer from auth.auth import require_session_login, process_oauth -from auth.permissions import AdministerOrganizationPermission, ReadRepositoryPermission +from auth.permissions import (AdministerOrganizationPermission, ReadRepositoryPermission, + SuperUserPermission) + from util.invoice import renderInvoiceToPdf from util.seo import render_snapshot from util.cache import no_cache from endpoints.common import common_login, render_page_template, route_show_if, param_required -from endpoints.csrf import csrf_protect, generate_csrf_token +from endpoints.csrf import csrf_protect, generate_csrf_token, verify_csrf from endpoints.registry import set_cache_headers from util.names import parse_repository_name, parse_repository_name_and_tag from util.useremails import send_email_changed +from util.systemlogs import build_logs_archive from auth import scopes import features @@ -106,6 +109,7 @@ def organizations(): def user(): return index('') + @web.route('/superuser/') @no_cache @route_show_if(features.SUPER_USERS) @@ -113,6 +117,13 @@ def superuser(): return index('') +@web.route('/setup/') +@no_cache +@route_show_if(features.SUPER_USERS) +def setup(): + return index('') + + @web.route('/signin/') @no_cache def signin(redirect=None): @@ -471,3 +482,21 @@ def exchange_code_for_token(): provider = FlaskAuthorizationProvider() return provider.get_token(grant_type, client_id, client_secret, redirect_uri, code, scope=scope) + + +@web.route('/systemlogsarchive', methods=['GET']) +@process_oauth +@route_show_if(features.SUPER_USERS) +@no_cache +def download_logs_archive(): + # Note: We cannot use the decorator here because this is a GET method. That being said, this + # information is sensitive enough that we want the extra protection. + verify_csrf() + + if SuperUserPermission().can(): + archive_data = build_logs_archive(app) + return Response(archive_data, + mimetype="application/octet-stream", + headers={"Content-Disposition": "attachment;filename=erlogs.tar.gz"}) + + abort(403) diff --git a/external_libraries.py b/external_libraries.py index febf9abb1..3fa48c44a 100644 --- a/external_libraries.py +++ b/external_libraries.py @@ -18,15 +18,15 @@ EXTERNAL_JS = [ ] EXTERNAL_CSS = [ - 'netdna.bootstrapcdn.com/font-awesome/4.1.0/css/font-awesome.css', + 'netdna.bootstrapcdn.com/font-awesome/4.2.0/css/font-awesome.css', 'netdna.bootstrapcdn.com/bootstrap/3.0.0/css/bootstrap.no-icons.min.css', - 'fonts.googleapis.com/css?family=Droid+Sans:400,700', + 'fonts.googleapis.com/css?family=Source+Sans+Pro:400,700', ] EXTERNAL_FONTS = [ - 'netdna.bootstrapcdn.com/font-awesome/4.0.3/fonts/fontawesome-webfont.woff?v=4.0.3', - 'netdna.bootstrapcdn.com/font-awesome/4.0.3/fonts/fontawesome-webfont.ttf?v=4.0.3', - 'netdna.bootstrapcdn.com/font-awesome/4.0.3/fonts/fontawesome-webfont.svg?v=4.0.3', + 'netdna.bootstrapcdn.com/font-awesome/4.2.0/fonts/fontawesome-webfont.woff?v=4.2.0', + 'netdna.bootstrapcdn.com/font-awesome/4.2.0/fonts/fontawesome-webfont.ttf?v=4.2.0', + 'netdna.bootstrapcdn.com/font-awesome/4.2.0/fonts/fontawesome-webfont.svg?v=4.2.0', ] diff --git a/grunt/Gruntfile.js b/grunt/Gruntfile.js index e9cb14818..fb1307992 100644 --- a/grunt/Gruntfile.js +++ b/grunt/Gruntfile.js @@ -65,7 +65,8 @@ module.exports = function(grunt) { } }, quay: { - src: ['../static/partials/*.html', '../static/directives/*.html'], + src: ['../static/partials/*.html', '../static/directives/*.html', '../static/directives/*.html' + , '../static/directives/config/*.html'], dest: '../static/dist/template-cache.js' } }, diff --git a/requirements-nover.txt b/requirements-nover.txt index 3ac8c84a6..9b8707870 100644 --- a/requirements-nover.txt +++ b/requirements-nover.txt @@ -40,8 +40,10 @@ git+https://github.com/DevTable/aniso8601-fake.git git+https://github.com/DevTable/anunidecode.git git+https://github.com/DevTable/avatar-generator.git git+https://github.com/DevTable/pygithub.git +git+https://github.com/DevTable/container-cloud-config.git git+https://github.com/jplana/python-etcd.git gipc +pyOpenSSL pygpgme cachetools mock diff --git a/requirements.txt b/requirements.txt index 73ce4da45..4e51c6245 100644 --- a/requirements.txt +++ b/requirements.txt @@ -47,6 +47,7 @@ python-ldap==2.4.19 python-magic==0.4.6 pygpgme==0.3 pytz==2014.10 +pyOpenSSL==0.14 raven==5.1.1 redis==2.10.3 reportlab==2.7 @@ -63,5 +64,6 @@ git+https://github.com/DevTable/aniso8601-fake.git git+https://github.com/DevTable/anunidecode.git git+https://github.com/DevTable/avatar-generator.git git+https://github.com/DevTable/pygithub.git +git+https://github.com/DevTable/container-cloud-config.git git+https://github.com/NateFerrero/oauth2lib.git git+https://github.com/jplana/python-etcd.git diff --git a/static/css/core-ui.css b/static/css/core-ui.css new file mode 100644 index 000000000..a89f07f39 --- /dev/null +++ b/static/css/core-ui.css @@ -0,0 +1,705 @@ + +.co-options-menu .fa-gear { + color: #999; + cursor: pointer; +} + +.co-options-menu .dropdown.open .fa-gear { + color: #428BCA; +} + +.co-img-bg-network { + background: url('/static/img/network-tile.png') left top repeat, linear-gradient(30deg, #2277ad, #144768) no-repeat left top fixed; + background-color: #2277ad; + background-size: auto, 100% 100%; +} + +.co-m-navbar { + background-color: white; + margin: 0; + padding-left: 10px; +} + +.co-fx-box-shadow { + -webkit-box-shadow: 0 2px 2px rgba(0, 0, 0, 0.2); + -moz-box-shadow: 0 2px 2px rgba(0, 0, 0, 0.2); + -ms-box-shadow: 0 2px 2px rgba(0, 0, 0, 0.2); + -o-box-shadow: 0 2px 2px rgba(0, 0, 0, 0.2); + box-shadow: 0 2px 2px rgba(0, 0, 0, 0.2); +} + +.co-fx-box-shadow-heavy { + -webkit-box-shadow: 0px 2px 2px rgba(0, 0, 0, 0.4); + -moz-box-shadow: 0px 2px 2px rgba(0, 0, 0, 0.4); + -ms-box-shadow: 0px 2px 2px rgba(0, 0, 0, 0.4); + -o-box-shadow: 0px 2px 2px rgba(0, 0, 0, 0.4); + box-shadow: 0px 2px 2px rgba(0, 0, 0, 0.4); +} + +.co-fx-text-shadow { + text-shadow: rgba(0, 0, 0, 1) 1px 1px 2px; +} + +.co-nav-title { + height: 70px; + margin-top: -22px; +} + +.co-nav-title .co-nav-title-content { + color: white; + text-align: center; +} + +.co-tab-container { + padding: 0px; +} + +.co-tabs { + margin: 0px; + padding: 0px; + width: 82px; + background-color: #e8f1f6; + border-right: 1px solid #DDE7ED; + + display: table-cell; + float: none; + vertical-align: top; +} + +.co-tab-content { + width: 100%; + display: table-cell; + float: none; + padding: 20px; +} + +.co-tabs li { + list-style: none; + display: block; + border-bottom: 1px solid #DDE7ED; +} + + +.co-tabs li.active { + background-color: white; + border-right: 1px solid white; + margin-right: -1px; +} + +.co-tabs li a { + display: block; + width: 82px; + height: 82px; + line-height: 82px; + text-align: center; + font-size: 36px; + color: gray; +} + +.co-tabs li.active a { + color: black; +} + + +.co-main-content-panel { + margin-bottom: 20px; + background-color: #fff; + border: 1px solid transparent; + padding: 10px; + + -webkit-box-shadow: 0px 2px 2px rgba(0, 0, 0, 0.4); + -moz-box-shadow: 0px 2px 2px rgba(0, 0, 0, 0.4); + -ms-box-shadow: 0px 2px 2px rgba(0, 0, 0, 0.4); + -o-box-shadow: 0px 2px 2px rgba(0, 0, 0, 0.4); + box-shadow: 0px 2px 2px rgba(0, 0, 0, 0.4); +} + +.co-tab-panel { + padding: 0px; +} + + +.cor-log-box { + width: 100%; + height: 550px; + position: relative; +} + +.co-log-viewer { + position: absolute; + top: 20px; + left: 20px; + right: 20px; + height: 500px; + + padding: 20px; + + background: rgb(55, 55, 55); + border: 1px solid black; + color: white; + + overflow: scroll; +} + +.co-log-viewer .co-log-content { + font-family: Consolas, "Lucida Console", Monaco, monospace; + font-size: 12px; + white-space: pre; +} + +.cor-log-box .co-log-viewer-new-logs i { + margin-left: 10px; + display: inline-block; +} + +.cor-log-box .co-log-viewer-new-logs { + cursor: pointer; + position: absolute; + bottom: 40px; + right: 30px; + padding: 10px; + color: white; + border-radius: 10px; + background: rgba(72, 158, 72, 0.8); +} + +.co-panel { + margin-bottom: 40px; + + /*border: 1px solid #eee;*/ +} + +.co-panel .co-panel-heading img { + margin-right: 6px; + width: 24px; +} + +.co-panel .co-panel-heading i.fa { + margin-right: 6px; + width: 24px; + text-align: center; +} + +.co-panel .co-panel-heading { + padding: 6px; + /*background: #eee;*/ + border-bottom: 1px solid #eee; + + margin-bottom: 4px; + font-size: 135%; + padding-left: 10px; +} + +.co-panel .co-panel-body { + padding: 10px; +} + +.co-panel .co-panel-button-bar { + margin-top: 10px; + padding-top: 10px; + border-top: 1px solid #eee; +} + +.config-setup-tool-element .help-text { + margin-top: 6px; + color: #aaa; +} + +.config-setup-tool-element .description { + padding: 6px; +} + +.config-setup-tool-element .config-table > tbody > tr > td:first-child { + padding-top: 14px; + font-weight: bold; +} + +.config-setup-tool-element .config-table > tbody > tr > td.non-input { + padding-top: 8px; +} + +.config-setup-tool-element .config-table > tbody > tr > td { + padding: 8px; + vertical-align: top; +} + +.config-setup-tool-element .config-table > tbody > tr > td .config-numeric-field-element { + width: 100px; +} + +.config-setup-tool-element .config-table > tbody > tr > td .config-string-field-element { + width: 400px; +} + +.config-contact-field { + margin-bottom: 4px; +} + +.config-contact-field .dropdown button { + width: 100px; + text-align: left; +} + +.config-contact-field .dropdown button .caret { + float: right; + margin-top: 9px; +} + +.config-contact-field .dropdown button i.fa { + margin-right: 6px; + width: 14px; + text-align: center; + display: inline-block; +} + +.config-contact-field .form-control { + width: 350px; +} + +.config-list-field-element .empty { + color: #ccc; + margin-bottom: 10px; + display: block; +} + +.config-list-field-element input { + width: 350px; +} + +.config-setup-tool-element .inner-table { + margin-left: 10px; +} + +.config-setup-tool-element .inner-table tr td:first-child { + font-weight: bold; +} + +.config-setup-tool-element .inner-table td { + padding: 6px; +} + +.config-file-field-element input { + display: inline-block; + margin-left: 10px; +} + +.co-checkbox { + position: relative; +} + +.co-checkbox input { + display: none; +} + +.co-checkbox label { + position: relative; + padding-left: 28px; + cursor: pointer; +} + +.co-checkbox label:before { + content: ''; + cursor: pointer; + position: absolute; + width: 20px; + height: 20px; + top: 0; + left: 0; + border-radius: 4px; + + -webkit-box-shadow: inset 0px 1px 1px rgba(0,0,0,0.5), 0px 1px 0px rgba(255,255,255,.4); + -moz-box-shadow: inset 0px 1px 1px rgba(0,0,0,0.5), 0px 1px 0px rgba(255,255,255,.4); + box-shadow: inset 0px 1px 1px rgba(0,0,0,0.5), 0px 1px 0px rgba(255,255,255,.4); + + background: -webkit-linear-gradient(top, #222 0%, #45484d 100%); + background: -moz-linear-gradient(top, #222 0%, #45484d 100%); + background: -o-linear-gradient(top, #222 0%, #45484d 100%); + background: -ms-linear-gradient(top, #222 0%, #45484d 100%); + background: linear-gradient(top, #222 0%, #45484d 100%); + filter: progid:DXImageTransform.Microsoft.gradient( startColorstr='#222', endColorstr='#45484d',GradientType=0 ); +} + +.co-checkbox label:after { + -ms-filter: "progid:DXImageTransform.Microsoft.Alpha(Opacity=0)"; + filter: alpha(opacity=0); + opacity: 0; + content: ''; + position: absolute; + width: 11px; + height: 7px; + background: transparent; + top: 5px; + left: 4px; + border: 3px solid #fcfff4; + border-top: none; + border-right: none; + + -webkit-transform: rotate(-45deg); + -moz-transform: rotate(-45deg); + -o-transform: rotate(-45deg); + -ms-transform: rotate(-45deg); + transform: rotate(-45deg); +} + +.co-checkbox label:hover::after { + -ms-filter: "progid:DXImageTransform.Microsoft.Alpha(Opacity=30)"; + filter: alpha(opacity=30); + opacity: 0.3; +} + +.co-checkbox input[type=checkbox]:checked + label:after { + -ms-filter: "progid:DXImageTransform.Microsoft.Alpha(Opacity=100)"; + filter: alpha(opacity=100); + opacity: 1; + border: 3px solid rgb(26, 255, 26); + border-top: none; + border-right: none; +} + +.co-floating-bottom-bar { + height: 50px; +} + +.co-floating-bottom-bar.floating { + position: fixed; + bottom: 0px; +} + +.config-setup-tool .cor-floating-bottom-bar button i.fa { + margin-right: 6px; +} + +.config-setup-tool .service-verification { + padding: 20px; + background: #343434; + color: white; + margin-bottom: -14px; +} + +.config-setup-tool .service-verification-row { + margin-bottom: 6px; +} + +.config-setup-tool .service-verification-row .service-title { + font-variant: small-caps; + font-size: 145%; + vertical-align: middle; +} + +#validateAndSaveModal .fa-warning { + font-size: 22px; + margin-right: 10px; + vertical-align: middle; + color: rgb(255, 186, 53); +} + +#validateAndSaveModal .fa-check-circle { + font-size: 22px; + margin-right: 10px; + vertical-align: middle; + color: rgb(53, 186, 53); +} + +.config-setup-tool .service-verification-error { + white-space: pre; + margin-top: 10px; + margin-left: 36px; + margin-bottom: 20px; + max-height: 250px; + overflow: auto; + border: 1px solid #797979; + background: black; + padding: 6px; + font-family: Consolas, "Lucida Console", Monaco, monospace; + font-size: 12px; +} + +.co-m-loader, .co-m-inline-loader { + min-width: 28px; } + +.co-m-loader { + display: block; + position: absolute; + left: 50%; + top: 50%; + margin: -11px 0 0 -13px; } + +.co-m-inline-loader { + display: inline-block; + cursor: default; } + .co-m-inline-loader:hover { + text-decoration: none; } + +.co-m-loader-dot__one, .co-m-loader-dot__two, .co-m-loader-dot__three { + -webkit-border-radius: 3px; + -moz-border-radius: 3px; + -ms-border-radius: 3px; + -o-border-radius: 3px; + border-radius: 3px; + animation-fill-mode: both; + -webkit-animation-fill-mode: both; + -moz-animation-fill-mode: both; + -ms-animation-fill-mode: both; + -o-animation-fill-mode: both; + animation-name: bouncedelay; + animation-duration: 1s; + animation-timing-function: ease-in-out; + animation-delay: 0; + animation-direction: normal; + animation-iteration-count: infinite; + animation-fill-mode: forwards; + animation-play-state: running; + -webkit-animation-name: bouncedelay; + -webkit-animation-duration: 1s; + -webkit-animation-timing-function: ease-in-out; + -webkit-animation-delay: 0; + -webkit-animation-direction: normal; + -webkit-animation-iteration-count: infinite; + -webkit-animation-fill-mode: forwards; + -webkit-animation-play-state: running; + -moz-animation-name: bouncedelay; + -moz-animation-duration: 1s; + -moz-animation-timing-function: ease-in-out; + -moz-animation-delay: 0; + -moz-animation-direction: normal; + -moz-animation-iteration-count: infinite; + -moz-animation-fill-mode: forwards; + -moz-animation-play-state: running; + display: inline-block; + height: 6px; + width: 6px; + background: #419eda; + border-radius: 100%; + display: inline-block; } + +.co-m-loader-dot__one { + animation-delay: -0.32s; + -webkit-animation-delay: -0.32s; + -moz-animation-delay: -0.32s; + -ms-animation-delay: -0.32s; + -o-animation-delay: -0.32s; } + +.co-m-loader-dot__two { + animation-delay: -0.16s; + -webkit-animation-delay: -0.16s; + -moz-animation-delay: -0.16s; + -ms-animation-delay: -0.16s; + -o-animation-delay: -0.16s; } + +@-webkit-keyframes bouncedelay { + 0%, 80%, 100% { + -webkit-transform: scale(0.25, 0.25); + -moz-transform: scale(0.25, 0.25); + -ms-transform: scale(0.25, 0.25); + -o-transform: scale(0.25, 0.25); + transform: scale(0.25, 0.25); } + + 40% { + -webkit-transform: scale(1, 1); + -moz-transform: scale(1, 1); + -ms-transform: scale(1, 1); + -o-transform: scale(1, 1); + transform: scale(1, 1); } } + +@-moz-keyframes bouncedelay { + 0%, 80%, 100% { + -webkit-transform: scale(0.25, 0.25); + -moz-transform: scale(0.25, 0.25); + -ms-transform: scale(0.25, 0.25); + -o-transform: scale(0.25, 0.25); + transform: scale(0.25, 0.25); } + + 40% { + -webkit-transform: scale(1, 1); + -moz-transform: scale(1, 1); + -ms-transform: scale(1, 1); + -o-transform: scale(1, 1); + transform: scale(1, 1); } } + +@-ms-keyframes bouncedelay { + 0%, 80%, 100% { + -webkit-transform: scale(0.25, 0.25); + -moz-transform: scale(0.25, 0.25); + -ms-transform: scale(0.25, 0.25); + -o-transform: scale(0.25, 0.25); + transform: scale(0.25, 0.25); } + + 40% { + -webkit-transform: scale(1, 1); + -moz-transform: scale(1, 1); + -ms-transform: scale(1, 1); + -o-transform: scale(1, 1); + transform: scale(1, 1); } } + +@keyframes bouncedelay { + 0%, 80%, 100% { + -webkit-transform: scale(0.25, 0.25); + -moz-transform: scale(0.25, 0.25); + -ms-transform: scale(0.25, 0.25); + -o-transform: scale(0.25, 0.25); + transform: scale(0.25, 0.25); } + + 40% { + -webkit-transform: scale(1, 1); + -moz-transform: scale(1, 1); + -ms-transform: scale(1, 1); + -o-transform: scale(1, 1); + transform: scale(1, 1); } } + +.co-dialog .modal-body { + padding: 10px; + min-height: 100px; +} + +.co-dialog .modal-body h4 { + margin-bottom: 20px; +} + +.co-dialog .modal-content { + border-radius: 0px; +} + +.co-dialog.fatal-error .modal-content { + padding-left: 175px; +} + +.co-dialog.fatal-error .alert-icon-container-container { + position: absolute; + top: -36px; + left: -175px; + bottom: 20px; +} + +.co-dialog.fatal-error .alert-icon-container { + height: 100%; + display: table; +} + +.co-dialog.fatal-error .alert-icon { + display: table-cell; + vertical-align: middle; + border-right: 1px solid #eee; + margin-right: 20px; +} + +.co-dialog.fatal-error .alert-icon:before { + content: "\f071"; + font-family: FontAwesome; + font-size: 60px; + padding-left: 50px; + padding-right: 50px; + color: #c53c3f; + text-align: center; +} + + +.co-dialog .modal-header .cor-step-bar { + float: right; +} + +.co-dialog .modal-footer.working { + text-align: left; +} + +.co-dialog .modal-footer.working .btn { + float: right; +} + +.co-dialog .modal-footer.working .cor-loader-inline { + margin-right: 10px; +} + +.co-dialog .modal-footer .left-align { + float: left; + vertical-align: middle; + font-size: 16px; + margin-top: 8px; +} + +.co-dialog .modal-footer .left-align i.fa-warning { + color: #ffba35; + display: inline-block; + margin-right: 6px; +} + +.co-dialog .modal-footer .left-align i.fa-check { + color: green; + display: inline-block; + margin-right: 6px; +} + +.co-step-bar .co-step-element { + cursor: default; + display: inline-block; + width: 28px; + height: 28px; + + position: relative; + color: #ddd; + + text-align: center; + line-height: 24px; + font-size: 16px; +} + +.co-step-bar .co-step-element.text { + margin-left: 24px; + background: white; +} + +.co-step-bar .co-step-element.icon { + margin-left: 22px; +} + +.co-step-bar .co-step-element:first-child { + margin-left: 0px; +} + +.co-step-bar .co-step-element.active { + color: #53a3d9; +} + +.co-step-bar .co-step-element:first-child:before { + display: none; +} + +.co-step-bar .co-step-element:before { + content: ""; + position: absolute; + top: 12px; + width: 14px; + border-top: 2px solid #ddd; +} + +.co-step-bar .co-step-element.icon:before { + left: -20px; +} + +.co-step-bar .co-step-element.text:before { + left: -22px; +} + +.co-step-bar .co-step-element.active:before { + border-top: 2px solid #53a3d9; +} + + +.co-step-bar .co-step-element.text { + border-radius: 100%; + border: 2px solid #ddd; +} + +.co-step-bar .co-step-element.text.active { + border: 2px solid #53a3d9; +} + +@media screen and (min-width: 900px) { + .co-dialog .modal-dialog { + width: 800px; + } +} + +.co-alert .co-step-bar { + float: right; + margin-top: 6px; +} \ No newline at end of file diff --git a/static/css/quay.css b/static/css/quay.css index ba5d0ea90..9046bf722 100644 --- a/static/css/quay.css +++ b/static/css/quay.css @@ -1,5 +1,5 @@ * { - font-family: 'Droid Sans', sans-serif; + font-family: 'Source Sans Pro', sans-serif; margin: 0; } @@ -88,34 +88,6 @@ margin: 0; } -.co-img-bg-network { - background: url('/static/img/network-tile.png') left top repeat, linear-gradient(30deg, #2277ad, #144768) no-repeat left top fixed; - background-color: #2277ad; - background-size: auto, 100% 100%; -} - -.co-m-navbar { - background-color: white; - margin: 0; - padding-left: 10px; -} - -.co-fx-box-shadow { - -webkit-box-shadow: 0 2px 2px rgba(0, 0, 0, 0.2); - -moz-box-shadow: 0 2px 2px rgba(0, 0, 0, 0.2); - -ms-box-shadow: 0 2px 2px rgba(0, 0, 0, 0.2); - -o-box-shadow: 0 2px 2px rgba(0, 0, 0, 0.2); - box-shadow: 0 2px 2px rgba(0, 0, 0, 0.2); -} - -.co-fx-box-shadow-heavy { - -webkit-box-shadow: 0px 2px 2px rgba(0, 0, 0, 0.4); - -moz-box-shadow: 0px 2px 2px rgba(0, 0, 0, 0.4); - -ms-box-shadow: 0px 2px 2px rgba(0, 0, 0, 0.4); - -o-box-shadow: 0px 2px 2px rgba(0, 0, 0, 0.4); - box-shadow: 0px 2px 2px rgba(0, 0, 0, 0.4); -} - .main-panel { margin-bottom: 20px; background-color: #fff; @@ -885,8 +857,8 @@ i.toggle-icon:hover { background-color: #f0ad4e; } -.phase-icon.priming-cache { - background-color: #ddd; +.phase-icon.priming-cache, .phase-icon.checking-cache { + background-color: #cab442; } .phase-icon.pushing { @@ -2614,7 +2586,7 @@ p.editable:hover i { } .repo-build .build-pane .build-logs .log-container.command { - margin-left: 42px; + margin-left: 22px; } .repo-build .build-pane .build-logs .container-header.building { @@ -4439,14 +4411,28 @@ pre.command:before { padding: 6px; } -.user-row.super-user td { - background-color: #eeeeee; +.user-row { + border-bottom: 0px; +} + +.user-row td { + vertical-align: middle; } .user-row .user-class { text-transform: uppercase; } +.user-row .labels { + float: right; + white-space: nowrap; +} + +.user-row .labels .label { + text-transform: uppercase; + margin-right: 10px; +} + .form-change input { margin-top: 12px; margin-bottom: 12px; @@ -4910,6 +4896,50 @@ i.slack-icon { margin-right: 10px; } +.system-log-download-panel { + padding: 20px; + text-align: center; + font-size: 18px; +} + +.system-log-download-panel a { + margin-top: 20px; +} + +.initial-setup-modal .quay-spinner { + vertical-align: middle; + margin-right: 10px; + display: inline-block; +} + +.initial-setup-modal .valid-database p { + font-size: 18px; +} + +.verified { + font-size: 16px; + margin-bottom: 16px; +} + +.verified i.fa { + font-size: 26px; + margin-right: 10px; + vertical-align: middle; + color: rgb(53, 186, 53); +} + +.registry-logo-preview { + border: 1px solid #eee; + vertical-align: middle; + padding: 4px; + max-width: 150px; +} + +.modal-footer.alert { + text-align: left; + margin-bottom: -16px; +} + .dockerfile-build-form table td { vertical-align: top; white-space: nowrap; @@ -4926,3 +4956,23 @@ i.slack-icon { padding-left: 22px; } +.restart-required { + position: relative; + padding-left: 54px; +} + +.restart-required button { + float: right; + margin-top: 4px; +} + +.restart-required button i.fa { + margin-right: 6px; +} + +.restart-required i.fa-warning { + position: absolute; + top: 24px; + left: 16px; + font-size: 28px; +} diff --git a/static/directives/config/config-bool-field.html b/static/directives/config/config-bool-field.html new file mode 100644 index 000000000..f3649d570 --- /dev/null +++ b/static/directives/config/config-bool-field.html @@ -0,0 +1,5 @@ +
+
+ +
+
diff --git a/static/directives/config/config-contact-field.html b/static/directives/config/config-contact-field.html new file mode 100644 index 000000000..9da7dc158 --- /dev/null +++ b/static/directives/config/config-contact-field.html @@ -0,0 +1,46 @@ +
+ + + + + +
+ + +
+ +
+
+
\ No newline at end of file diff --git a/static/directives/config/config-contacts-field.html b/static/directives/config/config-contacts-field.html new file mode 100644 index 000000000..c658867c8 --- /dev/null +++ b/static/directives/config/config-contacts-field.html @@ -0,0 +1,4 @@ +
+
+
+
\ No newline at end of file diff --git a/static/directives/config/config-file-field.html b/static/directives/config/config-file-field.html new file mode 100644 index 000000000..7e4710905 --- /dev/null +++ b/static/directives/config/config-file-field.html @@ -0,0 +1,10 @@ +
+ + {{ filename }} + {{ filename }} not found in mounted config directory: + + + + Uploading file as {{ filename }}... {{ uploadProgress }}% + +
diff --git a/static/directives/config/config-list-field.html b/static/directives/config/config-list-field.html new file mode 100644 index 000000000..e3f03a4ae --- /dev/null +++ b/static/directives/config/config-list-field.html @@ -0,0 +1,16 @@ +
+
    +
  • + {{ item }} + + Remove + +
  • +
+ No {{ itemTitle }}s defined +
+ + +
+
diff --git a/static/directives/config/config-numeric-field.html b/static/directives/config/config-numeric-field.html new file mode 100644 index 000000000..8c25a2fea --- /dev/null +++ b/static/directives/config/config-numeric-field.html @@ -0,0 +1,6 @@ +
+
+ +
+
diff --git a/static/directives/config/config-parsed-field.html b/static/directives/config/config-parsed-field.html new file mode 100644 index 000000000..2e0117d35 --- /dev/null +++ b/static/directives/config/config-parsed-field.html @@ -0,0 +1 @@ +
\ No newline at end of file diff --git a/static/directives/config/config-setup-tool.html b/static/directives/config/config-setup-tool.html new file mode 100644 index 000000000..6b40f1fd5 --- /dev/null +++ b/static/directives/config/config-setup-tool.html @@ -0,0 +1,625 @@ +
+
+
+
+ + +
+
+ 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. +
+
User Creation: +
+ + +
+
+ If enabled, user accounts can be created by anyone. + Users can always be created in the users panel under this superuser view. +
+
+
+
+ + +
+
+ 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 +
+
SSL: +
+ + +
+
+ A valid SSL certificate and private key files are required to use this option. +
+ + + + + + + + + +
Certificate: + +
+ The certificate must be in PEM format. +
+
Private key: + +
+
+ +
+
+ + +
+
+ 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-avaliability systems. +

+ + + + + + + + + + + + +
Storage Engine: + +
{{ field.title }}: + +
+ + +
+
+ See Documentation for more information +
+
+ +
+
+
+ + +
+
+ E-mail +
+
+
+

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

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

+ Authentication for the registry can be handled by either the registry itself or LDAP. + External authentication providers (such as Github) can be used on top of this choice. +

+
+ + + + + + +
Authentication: + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
LDAP URI:
Administrator DN:
Base DN:
Administrator Password:
E-mail Attribute:
UID Attribute:
User RDN:
+
+
+ + +
+
+ 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 + +

+
+ +
+ + +
+ + + + + + + + + + + + + + + + + + +
Github: + +
Github Endpoint: + + +
+ The Github Enterprise endpoint. Must start with http:// or https://. +
+
OAuth Client ID: + + +
OAuth Client Secret: + + +
+
+
+ + +
+
+ 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. +

+
+ +
+ + +
+ + + + + + + + + + +
OAuth Client ID: + + +
OAuth Client Secret: + + +
+
+
+ + +
+
+ Dockerfile Build Support +
+
+
+ If enabled, users can submit Dockerfiles to be built and pushed by the Enterprise Registry. +
+ +
+ + +
+ +
+ 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 + +

+
+ +
+ + +
+ + + + + + + + + + + + + + + + + + +
Github: + +
Github Endpoint: + + +
+ The Github Enterprise endpoint. Must start with http:// or https://. +
+
OAuth Client ID: + + +
OAuth Client Secret: + + +
+
+
+
+ + +
+ + +
+ + + + +
+
\ No newline at end of file diff --git a/static/directives/config/config-string-field.html b/static/directives/config/config-string-field.html new file mode 100644 index 000000000..7714fd541 --- /dev/null +++ b/static/directives/config/config-string-field.html @@ -0,0 +1,10 @@ +
+
+ +
+ {{ errorMessage }} +
+
+
diff --git a/static/directives/config/config-variable-field.html b/static/directives/config/config-variable-field.html new file mode 100644 index 000000000..9236469cd --- /dev/null +++ b/static/directives/config/config-variable-field.html @@ -0,0 +1,10 @@ +
+
+ +
+ + +
diff --git a/static/directives/cor-floating-bottom-bar.html b/static/directives/cor-floating-bottom-bar.html new file mode 100644 index 000000000..2e5337fd2 --- /dev/null +++ b/static/directives/cor-floating-bottom-bar.html @@ -0,0 +1,3 @@ +
+ +
\ No newline at end of file diff --git a/static/directives/cor-loader-inline.html b/static/directives/cor-loader-inline.html new file mode 100644 index 000000000..39ffb5b99 --- /dev/null +++ b/static/directives/cor-loader-inline.html @@ -0,0 +1,5 @@ +
+
+
+
+
\ No newline at end of file diff --git a/static/directives/cor-loader.html b/static/directives/cor-loader.html new file mode 100644 index 000000000..112680a22 --- /dev/null +++ b/static/directives/cor-loader.html @@ -0,0 +1,5 @@ +
+
+
+
+
\ No newline at end of file diff --git a/static/directives/cor-log-box.html b/static/directives/cor-log-box.html new file mode 100644 index 000000000..c5442d0f7 --- /dev/null +++ b/static/directives/cor-log-box.html @@ -0,0 +1,11 @@ +
+
+
+
+
{{ logs }}
+
+
+
+ New Logs +
+
\ No newline at end of file diff --git a/static/directives/cor-option.html b/static/directives/cor-option.html new file mode 100644 index 000000000..0eb57170b --- /dev/null +++ b/static/directives/cor-option.html @@ -0,0 +1,3 @@ +
  • + +
  • \ No newline at end of file diff --git a/static/directives/cor-options-menu.html b/static/directives/cor-options-menu.html new file mode 100644 index 000000000..8b6cf1e26 --- /dev/null +++ b/static/directives/cor-options-menu.html @@ -0,0 +1,6 @@ + + + \ No newline at end of file diff --git a/static/directives/cor-step-bar.html b/static/directives/cor-step-bar.html new file mode 100644 index 000000000..274a2c924 --- /dev/null +++ b/static/directives/cor-step-bar.html @@ -0,0 +1,3 @@ +
    + +
    \ No newline at end of file diff --git a/static/directives/cor-step.html b/static/directives/cor-step.html new file mode 100644 index 000000000..5339db30e --- /dev/null +++ b/static/directives/cor-step.html @@ -0,0 +1,6 @@ + + + {{ text }} + + + \ No newline at end of file diff --git a/static/directives/cor-tab-content.html b/static/directives/cor-tab-content.html new file mode 100644 index 000000000..997ae5af1 --- /dev/null +++ b/static/directives/cor-tab-content.html @@ -0,0 +1 @@ +
    \ No newline at end of file diff --git a/static/directives/cor-tab-panel.html b/static/directives/cor-tab-panel.html new file mode 100644 index 000000000..57f9dfa1c --- /dev/null +++ b/static/directives/cor-tab-panel.html @@ -0,0 +1,3 @@ +
    +
    +
    \ No newline at end of file diff --git a/static/directives/cor-tab.html b/static/directives/cor-tab.html new file mode 100644 index 000000000..f22d3bdac --- /dev/null +++ b/static/directives/cor-tab.html @@ -0,0 +1,11 @@ +
  • + + + +
  • \ No newline at end of file diff --git a/static/directives/cor-tabs.html b/static/directives/cor-tabs.html new file mode 100644 index 000000000..1a965932e --- /dev/null +++ b/static/directives/cor-tabs.html @@ -0,0 +1 @@ +
      \ No newline at end of file diff --git a/static/directives/cor-title-content.html b/static/directives/cor-title-content.html new file mode 100644 index 000000000..6acbe47b3 --- /dev/null +++ b/static/directives/cor-title-content.html @@ -0,0 +1,3 @@ +
      +

      +
      \ No newline at end of file diff --git a/static/directives/cor-title-link.html b/static/directives/cor-title-link.html new file mode 100644 index 000000000..396a1f447 --- /dev/null +++ b/static/directives/cor-title-link.html @@ -0,0 +1 @@ +
      \ No newline at end of file diff --git a/static/directives/cor-title.html b/static/directives/cor-title.html new file mode 100644 index 000000000..63cfd322c --- /dev/null +++ b/static/directives/cor-title.html @@ -0,0 +1,2 @@ +
      + diff --git a/static/img/redis-small.png b/static/img/redis-small.png new file mode 100644 index 000000000..1452e2f03 Binary files /dev/null and b/static/img/redis-small.png differ diff --git a/static/js/app.js b/static/js/app.js index b41e80b10..ed9c7a842 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -126,7 +126,7 @@ function getMarkedDown(string) { quayDependencies = ['ngRoute', 'chieffancypants.loadingBar', 'angular-tour', 'restangular', 'angularMoment', 'mgcrea.ngStrap', 'ngCookies', 'ngSanitize', 'angular-md5', 'pasvaz.bindonce', 'ansiToHtml', - 'ngAnimate']; + 'ngAnimate', 'core-ui', 'core-config-setup']; if (window.__config && window.__config.MIXPANEL_KEY) { quayDependencies.push('angulartics'); @@ -977,7 +977,7 @@ quayApp = angular.module('quay', quayDependencies, function($provide, cfpLoading return resource; }; - var buildUrl = function(path, parameters) { + var buildUrl = function(path, parameters, opt_forcessl) { // We already have /api/v1/ on the URLs, so remove them from the paths. path = path.substr('/api/v1/'.length, path.length); @@ -1017,6 +1017,11 @@ quayApp = angular.module('quay', quayDependencies, function($provide, cfpLoading } } + // If we are forcing SSL, return an absolutel URL with an SSL prefix. + if (opt_forcessl) { + path = 'https://' + window.location.host + '/api/v1/' + path; + } + return url; }; @@ -1047,12 +1052,35 @@ quayApp = angular.module('quay', quayDependencies, function($provide, cfpLoading } }; + var freshLoginInProgress = []; + var reject = function(msg) { + for (var i = 0; i < freshLoginInProgress.length; ++i) { + freshLoginInProgress[i].deferred.reject({'data': {'message': msg}}); + } + freshLoginInProgress = []; + }; + + var retry = function() { + for (var i = 0; i < freshLoginInProgress.length; ++i) { + freshLoginInProgress[i].retry(); + } + freshLoginInProgress = []; + }; + var freshLoginFailCheck = function(opName, opArgs) { return function(resp) { var deferred = $q.defer(); // If the error is a fresh login required, show the dialog. if (resp.status == 401 && resp.data['error_type'] == 'fresh_login_required') { + var retryOperation = function() { + apiService[opName].apply(apiService, opArgs).then(function(resp) { + deferred.resolve(resp); + }, function(resp) { + deferred.reject(resp); + }); + }; + var verifyNow = function() { var info = { 'password': $('#freshPassword').val() @@ -1062,19 +1090,27 @@ quayApp = angular.module('quay', quayDependencies, function($provide, cfpLoading // Conduct the sign in of the user. apiService.verifyUser(info).then(function() { - // On success, retry the operation. if it succeeds, then resolve the + // On success, retry the operations. if it succeeds, then resolve the // deferred promise with the result. Otherwise, reject the same. - apiService[opName].apply(apiService, opArgs).then(function(resp) { - deferred.resolve(resp); - }, function(resp) { - deferred.reject(resp); - }); + retry(); }, function(resp) { // Reject with the sign in error. - deferred.reject({'data': {'message': 'Invalid verification credentials'}}); + reject('Invalid verification credentials'); }); }; + // Add the retry call to the in progress list. If there is more than a single + // in progress call, we skip showing the dialog (since it has already been + // shown). + freshLoginInProgress.push({ + 'deferred': deferred, + 'retry': retryOperation + }) + + if (freshLoginInProgress.length > 1) { + return deferred.promise; + } + var box = bootbox.dialog({ "message": 'It has been more than a few minutes since you last logged in, ' + 'so please verify your password to perform this sensitive operation:' + @@ -1092,7 +1128,7 @@ quayApp = angular.module('quay', quayDependencies, function($provide, cfpLoading "label": "Cancel", "className": "btn-default", "callback": function() { - deferred.reject({'data': {'message': 'Verification canceled'}}); + reject('Verification canceled') } } } @@ -1124,8 +1160,8 @@ quayApp = angular.module('quay', quayDependencies, function($provide, cfpLoading var path = resource['path']; // Add the operation itself. - apiService[operationName] = function(opt_options, opt_parameters, opt_background) { - var one = Restangular.one(buildUrl(path, opt_parameters)); + apiService[operationName] = function(opt_options, opt_parameters, opt_background, opt_forcessl) { + var one = Restangular.one(buildUrl(path, opt_parameters, opt_forcessl)); if (opt_background) { one.withHttpConfig({ 'ignoreLoadingBar': true @@ -1244,6 +1280,39 @@ quayApp = angular.module('quay', quayDependencies, function($provide, cfpLoading return cookieService; }]); + $provide.factory('ContainerService', ['ApiService', '$timeout', + function(ApiService, $timeout) { + var containerService = {}; + containerService.restartContainer = function(callback) { + ApiService.scShutdownContainer(null, null).then(function(resp) { + $timeout(callback, 2000); + }, ApiService.errorDisplay('Cannot restart container. Please report this to support.')) + }; + + containerService.scheduleStatusCheck = function(callback) { + $timeout(function() { + containerService.checkStatus(callback); + }, 2000); + }; + + containerService.checkStatus = function(callback, force_ssl) { + var errorHandler = function(resp) { + if (resp.status == 404 || resp.status == 502) { + // Container has not yet come back up, so we schedule another check. + containerService.scheduleStatusCheck(callback); + return; + } + + return ApiService.errorDisplay('Cannot load status. Please report this to support')(resp); + }; + + ApiService.scRegistryStatus(null, null) + .then(callback, errorHandler, /* background */true, /* force ssl*/force_ssl); + }; + + return containerService; + }]); + $provide.factory('UserService', ['ApiService', 'CookieService', '$rootScope', 'Config', function(ApiService, CookieService, $rootScope, Config) { var userResponse = { @@ -2225,8 +2294,10 @@ quayApp = angular.module('quay', quayDependencies, function($provide, cfpLoading templateUrl: '/static/partials/repo-list.html', controller: RepoListCtrl, reloadOnSearch: false}). when('/user/', {title: 'Account Settings', description:'Account settings for ' + title, templateUrl: '/static/partials/user-admin.html', reloadOnSearch: false, controller: UserAdminCtrl}). - when('/superuser/', {title: 'Superuser Admin Panel', description:'Admin panel for ' + title, templateUrl: '/static/partials/super-user.html', - reloadOnSearch: false, controller: SuperUserAdminCtrl}). + when('/superuser/', {title: 'Enterprise Registry Management', description:'Admin panel for ' + title, templateUrl: '/static/partials/super-user.html', + reloadOnSearch: false, controller: SuperUserAdminCtrl, newLayout: true}). + when('/setup/', {title: 'Enterprise Registry Setup', description:'Setup for ' + title, templateUrl: '/static/partials/setup.html', + reloadOnSearch: false, controller: SetupCtrl, newLayout: true}). when('/guide/', {title: 'Guide', description:'Guide to using private docker repositories on ' + title, templateUrl: '/static/partials/guide.html', controller: GuideCtrl}). @@ -3908,9 +3979,11 @@ quayApp.directive('registryName', function () { replace: false, transclude: true, restrict: 'C', - scope: {}, + scope: { + 'isShort': '=isShort' + }, controller: function($scope, $element, Config) { - $scope.name = Config.REGISTRY_TITLE; + $scope.name = $scope.isShort ? Config.REGISTRY_TITLE_SHORT : Config.REGISTRY_TITLE; } }; return directiveDefinitionObject; @@ -5751,6 +5824,9 @@ quayApp.directive('buildMessage', function () { case 'building': return 'Building image from Dockerfile'; + case 'checking-cache': + return 'Looking up cached images'; + case 'priming-cache': return 'Priming cache for build'; @@ -5807,6 +5883,7 @@ quayApp.directive('buildProgress', function () { break; case 'initializing': + case 'checking-cache': case 'starting': case 'waiting': case 'cannot_load': @@ -6701,6 +6778,7 @@ quayApp.directive('ngBlur', function() { }; }); + quayApp.directive("filePresent", [function () { return { restrict: 'A', @@ -6774,7 +6852,6 @@ quayApp.run(['$location', '$rootScope', 'Restangular', 'UserService', 'PlanServi var changeTab = function(activeTab, opt_timeout) { var checkCount = 0; - $timeout(function() { if (checkCount > 5) { return; } checkCount++; @@ -6838,6 +6915,8 @@ quayApp.run(['$location', '$rootScope', 'Restangular', 'UserService', 'PlanServi $rootScope.pageClass = current.$$route.pageClass; } + $rootScope.newLayout = !!current.$$route.newLayout; + if (current.$$route.description) { $rootScope.description = current.$$route.description; } else { @@ -6853,26 +6932,28 @@ quayApp.run(['$location', '$rootScope', 'Restangular', 'UserService', 'PlanServi // Setup deep linking of tabs. This will change the search field of the URL whenever a tab // is changed in the UI. - $('a[data-toggle="tab"]').on('shown.bs.tab', function (e) { - var tabName = e.target.getAttribute('data-target').substr(1); - $rootScope.$apply(function() { - var isDefaultTab = $('a[data-toggle="tab"]')[0] == e.target; - var newSearch = $.extend($location.search(), {}); - if (isDefaultTab) { - delete newSearch['tab']; - } else { - newSearch['tab'] = tabName; - } + $timeout(function() { + $('a[data-toggle="tab"]').on('shown.bs.tab', function (e) { + var tabName = e.target.getAttribute('data-target').substr(1); + $rootScope.$apply(function() { + var isDefaultTab = $('a[data-toggle="tab"]')[0] == e.target; + var newSearch = $.extend($location.search(), {}); + if (isDefaultTab) { + delete newSearch['tab']; + } else { + newSearch['tab'] = tabName; + } - $location.search(newSearch); + $location.search(newSearch); + }); + + e.preventDefault(); }); - e.preventDefault(); - }); - - if (activeTab) { - changeTab(activeTab); - } + if (activeTab) { + changeTab(activeTab); + } + }, 400); // 400ms to make sure angular has rendered. }); var initallyChecked = false; diff --git a/static/js/controllers.js b/static/js/controllers.js index 9bea2ebdb..d78ef4bd5 100644 --- a/static/js/controllers.js +++ b/static/js/controllers.js @@ -2809,138 +2809,6 @@ function ManageApplicationCtrl($scope, $routeParams, $rootScope, $location, $tim loadApplicationInfo(); } - -function SuperUserAdminCtrl($scope, ApiService, Features, UserService) { - if (!Features.SUPER_USERS) { - return; - } - - // Monitor any user changes and place the current user into the scope. - UserService.updateUserIn($scope); - - $scope.logsCounter = 0; - $scope.newUser = {}; - $scope.createdUsers = []; - $scope.systemUsage = null; - - $scope.getUsage = function() { - if ($scope.systemUsage) { return; } - - ApiService.getSystemUsage().then(function(resp) { - $scope.systemUsage = resp; - }, ApiService.errorDisplay('Cannot load system usage. Please contact support.')) - } - - $scope.loadLogs = function() { - $scope.logsCounter++; - }; - - $scope.loadUsers = function() { - if ($scope.users) { - return; - } - - $scope.loadUsersInternal(); - }; - - $scope.loadUsersInternal = function() { - ApiService.listAllUsers().then(function(resp) { - $scope.users = resp['users']; - $scope.showInterface = true; - }, function(resp) { - $scope.users = []; - $scope.usersError = resp['data']['message'] || resp['data']['error_description']; - }); - }; - - $scope.showChangePassword = function(user) { - $scope.userToChange = user; - $('#changePasswordModal').modal({}); - }; - - $scope.createUser = function() { - $scope.creatingUser = true; - var errorHandler = ApiService.errorDisplay('Cannot create user', function() { - $scope.creatingUser = false; - }); - - ApiService.createInstallUser($scope.newUser, null).then(function(resp) { - $scope.creatingUser = false; - $scope.newUser = {}; - $scope.createdUsers.push(resp); - }, errorHandler) - }; - - $scope.showDeleteUser = function(user) { - if (user.username == UserService.currentUser().username) { - bootbox.dialog({ - "message": 'Cannot delete yourself!', - "title": "Cannot delete user", - "buttons": { - "close": { - "label": "Close", - "className": "btn-primary" - } - } - }); - return; - } - - $scope.userToDelete = user; - $('#confirmDeleteUserModal').modal({}); - }; - - $scope.changeUserPassword = function(user) { - $('#changePasswordModal').modal('hide'); - - var params = { - 'username': user.username - }; - - var data = { - 'password': user.password - }; - - ApiService.changeInstallUser(data, params).then(function(resp) { - $scope.loadUsersInternal(); - }, ApiService.errorDisplay('Could not change user')); - }; - - $scope.deleteUser = function(user) { - $('#confirmDeleteUserModal').modal('hide'); - - var params = { - 'username': user.username - }; - - ApiService.deleteInstallUser(null, params).then(function(resp) { - $scope.loadUsersInternal(); - }, ApiService.errorDisplay('Cannot delete user')); - }; - - $scope.sendRecoveryEmail = function(user) { - var params = { - 'username': user.username - }; - - ApiService.sendInstallUserRecoveryEmail(null, params).then(function(resp) { - bootbox.dialog({ - "message": "A recovery email has been sent to " + resp['email'], - "title": "Recovery email sent", - "buttons": { - "close": { - "label": "Close", - "className": "btn-primary" - } - } - }); - - }, ApiService.errorDisplay('Cannot send recovery email')) - }; - - $scope.loadUsers(); -} - function TourCtrl($scope, $location) { $scope.kind = $location.path().substring('/tour/'.length); } diff --git a/static/js/controllers/setup.js b/static/js/controllers/setup.js new file mode 100644 index 000000000..9dc76a17f --- /dev/null +++ b/static/js/controllers/setup.js @@ -0,0 +1,282 @@ +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.$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; + window.location = prefix + '://' + hostname + '/superuser'; + }; + + $scope.configurationSaved = function(config) { + $scope.hasSSL = config['PREFERRED_URL_SCHEME'] == 'https'; + $scope.hostname = config['SERVER_HOSTNAME']; + $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.showInvalidConfigDialog = function() { + var message = "The config.yaml file found in conf/stack could not be parsed." + var title = "Invalid configuration file"; + CoreDialog.fatal(title, message); + }; + + + $scope.showMissingConfigDialog = function() { + var message = "A volume should be mounted into the container at /conf/stack: " + + "

      docker run -v /path/to/config:/conf/stack
      " + + "
      Once fixed, restart the container. For more information, " + + "" + + "Read the Setup Guide" + + var title = "Missing configuration volume"; + CoreDialog.fatal(title, message); + }; + + $scope.parseDbUri = function(value) { + if (!value) { return null; } + + // Format: mysql+pymysql://:@/ + var uri = URI(value); + return { + 'kind': uri.protocol(), + 'username': uri.username(), + 'password': uri.password(), + 'server': uri.host(), + 'database': uri.path() ? uri.path().substr(1) : '' + }; + }; + + $scope.serializeDbUri = function(fields) { + if (!fields['server']) { return ''; } + + try { + if (!fields['server']) { return ''; } + if (!fields['database']) { return ''; } + + var uri = URI(); + 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 + }; + + 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.hasSSL); + }; + + // Load the initial status. + $scope.checkStatus(); +} \ No newline at end of file diff --git a/static/js/controllers/superuser.js b/static/js/controllers/superuser.js new file mode 100644 index 000000000..ddaee7d5c --- /dev/null +++ b/static/js/controllers/superuser.js @@ -0,0 +1,224 @@ +function SuperUserAdminCtrl($scope, $timeout, ApiService, Features, UserService, ContainerService, AngularPollChannel, CoreDialog) { + if (!Features.SUPER_USERS) { + return; + } + + // Monitor any user changes and place the current user into the scope. + UserService.updateUserIn($scope); + + $scope.configStatus = null; + $scope.requiresRestart = null; + $scope.logsCounter = 0; + $scope.newUser = {}; + $scope.createdUser = null; + $scope.systemUsage = null; + $scope.debugServices = null; + $scope.debugLogs = null; + $scope.pollChannel = null; + $scope.logsScrolled = false; + $scope.csrf_token = encodeURIComponent(window.__token); + + $scope.configurationSaved = function() { + $scope.requiresRestart = true; + }; + + $scope.showCreateUser = function() { + $scope.createdUser = null; + $('#createUserModal').modal('show'); + }; + + $scope.viewSystemLogs = function(service) { + if ($scope.pollChannel) { + $scope.pollChannel.stop(); + } + + $scope.debugService = service; + $scope.debugLogs = null; + + $scope.pollChannel = AngularPollChannel.create($scope, $scope.loadServiceLogs, 2 * 1000 /* 2s */); + $scope.pollChannel.start(); + }; + + $scope.loadServiceLogs = function(callback) { + if (!$scope.debugService) { return; } + + var params = { + 'service': $scope.debugService + }; + + var errorHandler = ApiService.errorDisplay('Cannot load system logs. Please contact support.', + function() { + callback(false); + }) + + ApiService.getSystemLogs(null, params, /* background */true).then(function(resp) { + $scope.debugLogs = resp['logs']; + callback(true); + }, errorHandler); + }; + + $scope.loadDebugServices = function() { + if ($scope.pollChannel) { + $scope.pollChannel.stop(); + } + + $scope.debugService = null; + + ApiService.listSystemLogServices().then(function(resp) { + $scope.debugServices = resp['services']; + }, ApiService.errorDisplay('Cannot load system logs. Please contact support.')) + }; + + $scope.getUsage = function() { + if ($scope.systemUsage) { return; } + + ApiService.getSystemUsage().then(function(resp) { + $scope.systemUsage = resp; + }, ApiService.errorDisplay('Cannot load system usage. Please contact support.')) + } + + $scope.loadUsageLogs = function() { + $scope.logsCounter++; + }; + + $scope.loadUsers = function() { + if ($scope.users) { + return; + } + + $scope.loadUsersInternal(); + }; + + $scope.loadUsersInternal = function() { + ApiService.listAllUsers().then(function(resp) { + $scope.users = resp['users']; + $scope.showInterface = true; + }, function(resp) { + $scope.users = []; + $scope.usersError = resp['data']['message'] || resp['data']['error_description']; + }); + }; + + $scope.showChangePassword = function(user) { + $scope.userToChange = user; + $('#changePasswordModal').modal({}); + }; + + $scope.createUser = function() { + $scope.creatingUser = true; + $scope.createdUser = null; + + var errorHandler = ApiService.errorDisplay('Cannot create user', function() { + $scope.creatingUser = false; + $('#createUserModal').modal('hide'); + }); + + ApiService.createInstallUser($scope.newUser, null).then(function(resp) { + $scope.creatingUser = false; + $scope.newUser = {}; + $scope.createdUser = resp; + $scope.loadUsersInternal(); + }, errorHandler) + }; + + $scope.showDeleteUser = function(user) { + if (user.username == UserService.currentUser().username) { + bootbox.dialog({ + "message": 'Cannot delete yourself!', + "title": "Cannot delete user", + "buttons": { + "close": { + "label": "Close", + "className": "btn-primary" + } + } + }); + return; + } + + $scope.userToDelete = user; + $('#confirmDeleteUserModal').modal({}); + }; + + $scope.changeUserPassword = function(user) { + $('#changePasswordModal').modal('hide'); + + var params = { + 'username': user.username + }; + + var data = { + 'password': user.password + }; + + ApiService.changeInstallUser(data, params).then(function(resp) { + $scope.loadUsersInternal(); + }, ApiService.errorDisplay('Could not change user')); + }; + + $scope.deleteUser = function(user) { + $('#confirmDeleteUserModal').modal('hide'); + + var params = { + 'username': user.username + }; + + ApiService.deleteInstallUser(null, params).then(function(resp) { + $scope.loadUsersInternal(); + }, ApiService.errorDisplay('Cannot delete user')); + }; + + $scope.sendRecoveryEmail = function(user) { + var params = { + 'username': user.username + }; + + ApiService.sendInstallUserRecoveryEmail(null, params).then(function(resp) { + bootbox.dialog({ + "message": "A recovery email has been sent to " + resp['email'], + "title": "Recovery email sent", + "buttons": { + "close": { + "label": "Close", + "className": "btn-primary" + } + } + }); + + }, ApiService.errorDisplay('Cannot send recovery email')) + }; + + $scope.restartContainer = function() { + $('#restartingContainerModal').modal({ + keyboard: false, + backdrop: 'static' + }); + + ContainerService.restartContainer(function() { + $scope.checkStatus() + }); + }; + + $scope.checkStatus = function() { + ContainerService.checkStatus(function(resp) { + $('#restartingContainerModal').modal('hide'); + $scope.configStatus = resp['status']; + $scope.requiresRestart = resp['requires_restart']; + + if ($scope.configStatus == 'ready') { + $scope.loadUsers(); + } else { + var message = "Installation of this product has not yet been completed." + + "

      Please read the " + + "" + + "Setup Guide" + + var title = "Installation Incomplete"; + CoreDialog.fatal(title, message); + } + }); + }; + + // Load the initial status. + $scope.checkStatus(); +} \ No newline at end of file diff --git a/static/js/core-config-setup.js b/static/js/core-config-setup.js new file mode 100644 index 000000000..cd7d9b356 --- /dev/null +++ b/static/js/core-config-setup.js @@ -0,0 +1,761 @@ +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) { + $scope.HOSTNAME_REGEX = '^[a-zA-Z-0-9\.]+(:[0-9]+)?$'; + $scope.GITHUB_REGEX = '^https?://([a-zA-Z0-9]+\.?\/?)+$'; + + $scope.SERVICES = [ + {'id': 'redis', 'title': 'Redis'}, + + {'id': 'registry-storage', 'title': 'Registry Storage'}, + + {'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'; + }}, + + {'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; + }} + ]; + + $scope.STORAGE_CONFIG_FIELDS = { + 'LocalStorage': [ + {'name': 'storage_path', 'title': 'Storage Directory', 'placeholder': '/some/directory', 'kind': 'text'} + ], + + 'S3Storage': [ + {'name': 's3_access_key', 'title': 'AWS Access Key', 'placeholder': 'accesskeyhere', 'kind': 'text'}, + {'name': 's3_secret_key', 'title': 'AWS Secret Key', 'placeholder': 'secretkeyhere', 'kind': 'text'}, + {'name': 's3_bucket', 'title': 'S3 Bucket', 'placeholder': 'my-cool-bucket', 'kind': 'text'}, + {'name': 'storage_path', 'title': 'Storage Directory', 'placeholder': '/path/inside/bucket', 'kind': 'text'} + ], + + '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': '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'} + ] + }; + + $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.validating = null; + $scope.savingConfiguration = false; + + $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) { + var params = { + 'service': serviceInfo.service.id + }; + + ApiService.scValidateConfig({'config': $scope.config}, params).then(function(resp) { + serviceInfo.status = resp.status ? 'success' : 'error'; + serviceInfo.errorMessage = $.trim(resp.reason || ''); + }, ApiService.errorDisplay('Could not validate configuration. Please report this error.')); + }; + + $scope.checkValidateAndSave = function() { + if ($scope.configform.$valid) { + $scope.validateAndSave(); + return; + } + + $element.find("input.ng-invalid:first")[0].scrollIntoView(); + $element.find("input.ng-invalid:first").focus(); + }; + + $scope.validateAndSave = function() { + $scope.savingConfiguration = false; + $scope.validating = $scope.getServices($scope.config); + + $('#validateAndSaveModal').modal({ + keyboard: false, + backdrop: 'static' + }); + + for (var i = 0; i < $scope.validating.length; ++i) { + var serviceInfo = $scope.validating[i]; + $scope.validateService(serviceInfo); + } + }; + + $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 + }; + + ApiService.scUpdateConfig(data).then(function(resp) { + $scope.savingConfiguration = false; + $scope.mapped.$hasChanges = false; + $('#validateAndSaveModal').modal('hide'); + $scope.configurationSaved({'config': $scope.config}); + }, ApiService.errorDisplay('Could not save configuration. Please report this error.')); + }; + + 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) { + 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'; + + $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'); + }; + + 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.redis.host', redisSetter('host')); + $scope.$watch('mapped.redis.port', redisSetter('port')); + $scope.$watch('mapped.redis.password', redisSetter('password')); + + // Add a watch to remove any fields not allowed by the current storage configuration. + // We have to do this otherwise extra fields (which are not allowed) can end up in the + // configuration. + $scope.$watch('config.DISTRIBUTED_STORAGE_CONFIG.local[0]', function(value) { + // Remove any fields not associated with the current kind. + if (!value || !$scope.STORAGE_CONFIG_FIELDS[value] + || !$scope.config.DISTRIBUTED_STORAGE_CONFIG + || !$scope.config.DISTRIBUTED_STORAGE_CONFIG.local + || !$scope.config.DISTRIBUTED_STORAGE_CONFIG.local[1]) { return; } + + var allowedFields = $scope.STORAGE_CONFIG_FIELDS[value]; + var configObject = $scope.config.DISTRIBUTED_STORAGE_CONFIG.local[1]; + + // 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 boolean fields to false. + for (var i = 0; i < allowedFields.length; ++i) { + if (allowedFields[i].kind == 'bool') { + configObject[allowedFields[i].name] = false; + } + } + }); + + $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); + $scope.mapped['$hasChanges'] = false; + }); + }); + } + }; + + 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' + }, + 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.$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' + }, + controller: function($scope, $element, Restangular, $upload) { + $scope.hasFile = false; + + $scope.onFileSelect = function(files) { + if (files.length < 1) { 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; + $scope.hasFile = true; + } + }).success(function(data, status, headers, config) { + $scope.uploadProgress = null; + $scope.hasFile = true; + }); + }; + + var loadStatus = function(filename) { + Restangular.one('superuser/config/file/' + filename).get().then(function(resp) { + $scope.hasFile = resp['exists']; + }); + }; + + if ($scope.filename) { + loadStatus($scope.filename); + } + } + }; + return directiveDefinitionObject; + }) + + .directive('configBoolField', function () { + var directiveDefinitionObject = { + priority: 0, + templateUrl: '/static/directives/config/config-bool-field.html', + replace: false, + transclude: false, + 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 || ''; + + 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('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' + }, + controller: function($scope, $element) { + $scope.getRegexp = function(pattern) { + if (!pattern) { + pattern = '.*'; + } + return new RegExp(pattern); + }; + + $scope.$watch('binding', function(binding) { + if (!binding && $scope.defaultValue) { + $scope.binding = $scope.defaultValue; + } + + $scope.errorMessage = $scope.validator({'value': binding || ''}); + }); + } + }; + return directiveDefinitionObject; + }); \ No newline at end of file diff --git a/static/js/core-ui.js b/static/js/core-ui.js new file mode 100644 index 000000000..fc1ea029c --- /dev/null +++ b/static/js/core-ui.js @@ -0,0 +1,329 @@ +angular.module("core-ui", []) + .factory('CoreDialog', [function() { + var service = {}; + service['fatal'] = function(title, message) { + bootbox.dialog({ + "title": title, + "message": "
      " + message, + "buttons": {}, + "className": "co-dialog fatal-error", + "closeButton": false + }); + }; + + return service; + }]) + + .directive('corLogBox', function() { + var directiveDefinitionObject = { + priority: 1, + templateUrl: '/static/directives/cor-log-box.html', + replace: true, + transclude: true, + restrict: 'C', + scope: { + 'logs': '=logs' + }, + controller: function($rootScope, $scope, $element, $timeout) { + $scope.hasNewLogs = false; + + var scrollHandlerBound = false; + var isAnimatedScrolling = false; + var isScrollBottom = true; + + var scrollHandler = function() { + if (isAnimatedScrolling) { return; } + var element = $element.find("#co-log-viewer")[0]; + isScrollBottom = element.scrollHeight - element.scrollTop === element.clientHeight; + if (isScrollBottom) { + $scope.hasNewLogs = false; + } + }; + + var animateComplete = function() { + isAnimatedScrolling = false; + }; + + $scope.moveToBottom = function() { + $scope.hasNewLogs = false; + isAnimatedScrolling = true; + isScrollBottom = true; + + $element.find("#co-log-viewer").animate( + { scrollTop: $element.find("#co-log-content").height() }, "slow", null, animateComplete); + }; + + $scope.$watch('logs', function(value, oldValue) { + if (!value) { return; } + + $timeout(function() { + if (!scrollHandlerBound) { + $element.find("#co-log-viewer").on('scroll', scrollHandler); + scrollHandlerBound = true; + } + + if (!isScrollBottom) { + $scope.hasNewLogs = true; + return; + } + + $scope.moveToBottom(); + }, 500); + }); + } + }; + return directiveDefinitionObject; + }) + + .directive('corOptionsMenu', function() { + var directiveDefinitionObject = { + priority: 1, + templateUrl: '/static/directives/cor-options-menu.html', + replace: true, + transclude: true, + restrict: 'C', + scope: {}, + controller: function($rootScope, $scope, $element) { + } + }; + return directiveDefinitionObject; + }) + + .directive('corOption', function() { + var directiveDefinitionObject = { + priority: 1, + templateUrl: '/static/directives/cor-option.html', + replace: true, + transclude: true, + restrict: 'C', + scope: { + 'optionClick': '&optionClick' + }, + controller: function($rootScope, $scope, $element) { + } + }; + return directiveDefinitionObject; + }) + + + .directive('corTitle', function() { + var directiveDefinitionObject = { + priority: 1, + templateUrl: '/static/directives/cor-title.html', + replace: true, + transclude: true, + restrict: 'C', + scope: {}, + controller: function($rootScope, $scope, $element) { + } + }; + return directiveDefinitionObject; + }) + + .directive('corTitleContent', function() { + var directiveDefinitionObject = { + priority: 1, + templateUrl: '/static/directives/cor-title-content.html', + replace: true, + transclude: true, + restrict: 'C', + scope: {}, + controller: function($rootScope, $scope, $element) { + } + }; + return directiveDefinitionObject; + }) + + .directive('corTitleLink', function() { + var directiveDefinitionObject = { + priority: 1, + templateUrl: '/static/directives/cor-title-link.html', + replace: true, + transclude: true, + restrict: 'C', + scope: {}, + controller: function($rootScope, $scope, $element) { + } + }; + return directiveDefinitionObject; + }) + + .directive('corTabPanel', function() { + var directiveDefinitionObject = { + priority: 1, + templateUrl: '/static/directives/cor-tab-panel.html', + replace: true, + transclude: true, + restrict: 'C', + scope: {}, + controller: function($rootScope, $scope, $element) { + } + }; + return directiveDefinitionObject; + }) + + .directive('corTabContent', function() { + var directiveDefinitionObject = { + priority: 2, + templateUrl: '/static/directives/cor-tab-content.html', + replace: true, + transclude: true, + restrict: 'C', + scope: {}, + controller: function($rootScope, $scope, $element) { + } + }; + return directiveDefinitionObject; + }) + + .directive('corTabs', function() { + var directiveDefinitionObject = { + priority: 3, + templateUrl: '/static/directives/cor-tabs.html', + replace: true, + transclude: true, + restrict: 'C', + scope: {}, + controller: function($rootScope, $scope, $element) { + } + }; + return directiveDefinitionObject; + }) + + .directive('corFloatingBottomBar', function() { + var directiveDefinitionObject = { + priority: 3, + templateUrl: '/static/directives/cor-floating-bottom-bar.html', + replace: true, + transclude: true, + restrict: 'C', + scope: {}, + controller: function($rootScope, $scope, $element, $timeout, $interval) { + var handler = function() { + $element.removeClass('floating'); + $element.css('width', $element[0].parentNode.clientWidth + 'px'); + + var windowHeight = $(window).height(); + var rect = $element[0].getBoundingClientRect(); + if (rect.bottom > windowHeight) { + $element.addClass('floating'); + } + }; + + $(window).on("scroll", handler); + $(window).on("resize", handler); + + var previousHeight = $element[0].parentNode.clientHeight; + var stop = $interval(function() { + var currentHeight = $element[0].parentNode.clientWidth; + if (previousHeight != currentHeight) { + currentHeight = previousHeight; + handler(); + } + }, 100); + + $scope.$on('$destroy', function() { + $(window).off("resize", handler); + $(window).off("scroll", handler); + $interval.cancel(stop); + }); + } + }; + return directiveDefinitionObject; + + }) + + .directive('corLoaderInline', function() { + var directiveDefinitionObject = { + templateUrl: '/static/directives/cor-loader-inline.html', + replace: true, + restrict: 'C', + scope: { + }, + controller: function($rootScope, $scope, $element) { + } + }; + return directiveDefinitionObject; + }) + + .directive('corLoader', function() { + var directiveDefinitionObject = { + templateUrl: '/static/directives/cor-loader.html', + replace: true, + restrict: 'C', + scope: { + }, + controller: function($rootScope, $scope, $element) { + } + }; + return directiveDefinitionObject; + }) + + .directive('corTab', function() { + var directiveDefinitionObject = { + priority: 4, + templateUrl: '/static/directives/cor-tab.html', + replace: true, + transclude: true, + restrict: 'C', + scope: { + 'tabActive': '@tabActive', + 'tabTitle': '@tabTitle', + 'tabTarget': '@tabTarget', + 'tabInit': '&tabInit' + }, + controller: function($rootScope, $scope, $element) { + } + }; + return directiveDefinitionObject; + }) + + .directive('corStep', function() { + var directiveDefinitionObject = { + priority: 4, + templateUrl: '/static/directives/cor-step.html', + replace: true, + transclude: false, + requires: '^corStepBar', + restrict: 'C', + scope: { + 'icon': '@icon', + 'title': '@title', + 'text': '@text' + }, + controller: function($rootScope, $scope, $element) { + } + }; + 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) { + 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; + }); \ No newline at end of file diff --git a/static/lib/LICENSES b/static/lib/LICENSES index 96df1ebf8..39ac45d5a 100644 --- a/static/lib/LICENSES +++ b/static/lib/LICENSES @@ -19,6 +19,7 @@ typeahead - Permissive (https://github.com/twitter/typeahead.js/blob/master/LICE zlib - MIT (https://github.com/imaya/zlib.js) pagedown - Permissive jquery.overscroll - MIT (https://github.com/azoff/overscroll/blob/master/mit.license) +URI.js - MIT (https://github.com/medialize/URI.js) Issues: >>>>> jquery.spotlight - GPLv3 (https://github.com/jameshalsall/jQuery-Spotlight) \ No newline at end of file diff --git a/static/lib/URI.min.js b/static/lib/URI.min.js new file mode 100644 index 000000000..f4c3a01dc --- /dev/null +++ b/static/lib/URI.min.js @@ -0,0 +1,78 @@ +(function(f,l){"object"===typeof exports?module.exports=l():"function"===typeof define&&define.amd?define(l):f.IPv6=l(f)})(this,function(f){var l=f&&f.IPv6;return{best:function(g){g=g.toLowerCase().split(":");var m=g.length,b=8;""===g[0]&&""===g[1]&&""===g[2]?(g.shift(),g.shift()):""===g[0]&&""===g[1]?g.shift():""===g[m-1]&&""===g[m-2]&&g.pop();m=g.length;-1!==g[m-1].indexOf(".")&&(b=7);var k;for(k=0;kf;f++)if("0"===m[0]&&1f&&(m=h,f=l)):"0"===g[k]&&(r=!0,h=k,l=1);l>f&&(m=h,f=l);1=c&&h>>10&1023|55296),b=56320|b&1023);return e+=B(b)}).join("")}function z(b, +e){return b+22+75*(26>b)-((0!=e)<<5)}function p(b,e,h){var a=0;b=h?q(b/700):b>>1;for(b+=q(b/e);455x&&(x=0);for(y=0;y=h&&l("invalid-input");f=b.charCodeAt(x++);f=10>f-48?f-22:26>f-65?f-65:26>f-97?f-97:36;(36<=f||f>q((2147483647-c)/a))&&l("overflow");c+=f*a;m= +g<=u?1:g>=u+26?26:g-u;if(fq(2147483647/f)&&l("overflow");a*=f}a=e.length+1;u=p(c-y,a,0==y);q(c/a)>2147483647-d&&l("overflow");d+=q(c/a);c%=a;e.splice(c++,0,d)}return k(e)}function r(e){var h,g,a,c,d,u,x,y,f,m=[],r,k,n;e=b(e);r=e.length;h=128;g=0;d=72;for(u=0;uf&&m.push(B(f));for((a=c=m.length)&&m.push("-");a=h&&fq((2147483647-g)/k)&&l("overflow");g+=(x-h)*k;h=x;for(u=0;u=d+26?26:x-d;if(y= 0x80 (not a basic code point)", +"invalid-input":"Invalid input"},q=Math.floor,B=String.fromCharCode,E;t={version:"1.2.3",ucs2:{decode:b,encode:k},decode:h,encode:r,toASCII:function(b){return m(b,function(b){return e.test(b)?"xn--"+r(b):b})},toUnicode:function(b){return m(b,function(b){return n.test(b)?h(b.slice(4).toLowerCase()):b})}};if("function"==typeof define&&"object"==typeof define.amd&&define.amd)define(function(){return t});else if(C&&!C.nodeType)if(D)D.exports=t;else for(E in t)t.hasOwnProperty(E)&&(C[E]=t[E]);else f.punycode= +t})(this);(function(f,l){"object"===typeof exports?module.exports=l():"function"===typeof define&&define.amd?define(l):f.SecondLevelDomains=l(f)})(this,function(f){var l=f&&f.SecondLevelDomains,g={list:{ac:" com gov mil net org ",ae:" ac co gov mil name net org pro sch ",af:" com edu gov net org ",al:" com edu gov mil net org ",ao:" co ed gv it og pb ",ar:" com edu gob gov int mil net org tur ",at:" ac co gv or ",au:" asn com csiro edu gov id net org ",ba:" co com edu gov mil net org rs unbi unmo unsa untz unze ", +bb:" biz co com edu gov info net org store tv ",bh:" biz cc com edu gov info net org ",bn:" com edu gov net org ",bo:" com edu gob gov int mil net org tv ",br:" adm adv agr am arq art ato b bio blog bmd cim cng cnt com coop ecn edu eng esp etc eti far flog fm fnd fot fst g12 ggf gov imb ind inf jor jus lel mat med mil mus net nom not ntr odo org ppg pro psc psi qsl rec slg srv tmp trd tur tv vet vlog wiki zlg ",bs:" com edu gov net org ",bz:" du et om ov rg ",ca:" ab bc mb nb nf nl ns nt nu on pe qc sk yk ", +ck:" biz co edu gen gov info net org ",cn:" ac ah bj com cq edu fj gd gov gs gx gz ha hb he hi hl hn jl js jx ln mil net nm nx org qh sc sd sh sn sx tj tw xj xz yn zj ",co:" com edu gov mil net nom org ",cr:" ac c co ed fi go or sa ",cy:" ac biz com ekloges gov ltd name net org parliament press pro tm ","do":" art com edu gob gov mil net org sld web ",dz:" art asso com edu gov net org pol ",ec:" com edu fin gov info med mil net org pro ",eg:" com edu eun gov mil name net org sci ",er:" com edu gov ind mil net org rochest w ", +es:" com edu gob nom org ",et:" biz com edu gov info name net org ",fj:" ac biz com info mil name net org pro ",fk:" ac co gov net nom org ",fr:" asso com f gouv nom prd presse tm ",gg:" co net org ",gh:" com edu gov mil org ",gn:" ac com gov net org ",gr:" com edu gov mil net org ",gt:" com edu gob ind mil net org ",gu:" com edu gov net org ",hk:" com edu gov idv net org ",hu:" 2000 agrar bolt casino city co erotica erotika film forum games hotel info ingatlan jogasz konyvelo lakas media news org priv reklam sex shop sport suli szex tm tozsde utazas video ", +id:" ac co go mil net or sch web ",il:" ac co gov idf k12 muni net org ","in":" ac co edu ernet firm gen gov i ind mil net nic org res ",iq:" com edu gov i mil net org ",ir:" ac co dnssec gov i id net org sch ",it:" edu gov ",je:" co net org ",jo:" com edu gov mil name net org sch ",jp:" ac ad co ed go gr lg ne or ",ke:" ac co go info me mobi ne or sc ",kh:" com edu gov mil net org per ",ki:" biz com de edu gov info mob net org tel ",km:" asso com coop edu gouv k medecin mil nom notaires pharmaciens presse tm veterinaire ", +kn:" edu gov net org ",kr:" ac busan chungbuk chungnam co daegu daejeon es gangwon go gwangju gyeongbuk gyeonggi gyeongnam hs incheon jeju jeonbuk jeonnam k kg mil ms ne or pe re sc seoul ulsan ",kw:" com edu gov net org ",ky:" com edu gov net org ",kz:" com edu gov mil net org ",lb:" com edu gov net org ",lk:" assn com edu gov grp hotel int ltd net ngo org sch soc web ",lr:" com edu gov net org ",lv:" asn com conf edu gov id mil net org ",ly:" com edu gov id med net org plc sch ",ma:" ac co gov m net org press ", +mc:" asso tm ",me:" ac co edu gov its net org priv ",mg:" com edu gov mil nom org prd tm ",mk:" com edu gov inf name net org pro ",ml:" com edu gov net org presse ",mn:" edu gov org ",mo:" com edu gov net org ",mt:" com edu gov net org ",mv:" aero biz com coop edu gov info int mil museum name net org pro ",mw:" ac co com coop edu gov int museum net org ",mx:" com edu gob net org ",my:" com edu gov mil name net org sch ",nf:" arts com firm info net other per rec store web ",ng:" biz com edu gov mil mobi name net org sch ", +ni:" ac co com edu gob mil net nom org ",np:" com edu gov mil net org ",nr:" biz com edu gov info net org ",om:" ac biz co com edu gov med mil museum net org pro sch ",pe:" com edu gob mil net nom org sld ",ph:" com edu gov i mil net ngo org ",pk:" biz com edu fam gob gok gon gop gos gov net org web ",pl:" art bialystok biz com edu gda gdansk gorzow gov info katowice krakow lodz lublin mil net ngo olsztyn org poznan pwr radom slupsk szczecin torun warszawa waw wroc wroclaw zgora ",pr:" ac biz com edu est gov info isla name net org pro prof ", +ps:" com edu gov net org plo sec ",pw:" belau co ed go ne or ",ro:" arts com firm info nom nt org rec store tm www ",rs:" ac co edu gov in org ",sb:" com edu gov net org ",sc:" com edu gov net org ",sh:" co com edu gov net nom org ",sl:" com edu gov net org ",st:" co com consulado edu embaixada gov mil net org principe saotome store ",sv:" com edu gob org red ",sz:" ac co org ",tr:" av bbs bel biz com dr edu gen gov info k12 name net org pol tel tsk tv web ",tt:" aero biz cat co com coop edu gov info int jobs mil mobi museum name net org pro tel travel ", +tw:" club com ebiz edu game gov idv mil net org ",mu:" ac co com gov net or org ",mz:" ac co edu gov org ",na:" co com ",nz:" ac co cri geek gen govt health iwi maori mil net org parliament school ",pa:" abo ac com edu gob ing med net nom org sld ",pt:" com edu gov int net nome org publ ",py:" com edu gov mil net org ",qa:" com edu gov mil net org ",re:" asso com nom ",ru:" ac adygeya altai amur arkhangelsk astrakhan bashkiria belgorod bir bryansk buryatia cbg chel chelyabinsk chita chukotka chuvashia com dagestan e-burg edu gov grozny int irkutsk ivanovo izhevsk jar joshkar-ola kalmykia kaluga kamchatka karelia kazan kchr kemerovo khabarovsk khakassia khv kirov koenig komi kostroma kranoyarsk kuban kurgan kursk lipetsk magadan mari mari-el marine mil mordovia mosreg msk murmansk nalchik net nnov nov novosibirsk nsk omsk orenburg org oryol penza perm pp pskov ptz rnd ryazan sakhalin samara saratov simbirsk smolensk spb stavropol stv surgut tambov tatarstan tom tomsk tsaritsyn tsk tula tuva tver tyumen udm udmurtia ulan-ude vladikavkaz vladimir vladivostok volgograd vologda voronezh vrn vyatka yakutia yamal yekaterinburg yuzhno-sakhalinsk ", +rw:" ac co com edu gouv gov int mil net ",sa:" com edu gov med net org pub sch ",sd:" com edu gov info med net org tv ",se:" a ac b bd c d e f g h i k l m n o org p parti pp press r s t tm u w x y z ",sg:" com edu gov idn net org per ",sn:" art com edu gouv org perso univ ",sy:" com edu gov mil net news org ",th:" ac co go in mi net or ",tj:" ac biz co com edu go gov info int mil name net nic org test web ",tn:" agrinet com defense edunet ens fin gov ind info intl mincom nat net org perso rnrt rns rnu tourism ", +tz:" ac co go ne or ",ua:" biz cherkassy chernigov chernovtsy ck cn co com crimea cv dn dnepropetrovsk donetsk dp edu gov if in ivano-frankivsk kh kharkov kherson khmelnitskiy kiev kirovograd km kr ks kv lg lugansk lutsk lviv me mk net nikolaev od odessa org pl poltava pp rovno rv sebastopol sumy te ternopil uzhgorod vinnica vn zaporizhzhe zhitomir zp zt ",ug:" ac co go ne or org sc ",uk:" ac bl british-library co cym gov govt icnet jet lea ltd me mil mod national-library-scotland nel net nhs nic nls org orgn parliament plc police sch scot soc ", +us:" dni fed isa kids nsn ",uy:" com edu gub mil net org ",ve:" co com edu gob info mil net org web ",vi:" co com k12 net org ",vn:" ac biz com edu gov health info int name net org pro ",ye:" co com gov ltd me net org plc ",yu:" ac co edu gov org ",za:" ac agric alt bourse city co cybernet db edu gov grondar iaccess imt inca landesign law mil net ngo nis nom olivetti org pix school tm web ",zm:" ac co com edu gov net org sch "},has:function(f){var b=f.lastIndexOf(".");if(0>=b||b>=f.length-1)return!1; +var k=f.lastIndexOf(".",b-1);if(0>=k||k>=b-1)return!1;var l=g.list[f.slice(b+1)];return l?0<=l.indexOf(" "+f.slice(k+1,b)+" "):!1},is:function(f){var b=f.lastIndexOf(".");if(0>=b||b>=f.length-1||0<=f.lastIndexOf(".",b-1))return!1;var k=g.list[f.slice(b+1)];return k?0<=k.indexOf(" "+f.slice(0,b)+" "):!1},get:function(f){var b=f.lastIndexOf(".");if(0>=b||b>=f.length-1)return null;var k=f.lastIndexOf(".",b-1);if(0>=k||k>=b-1)return null;var l=g.list[f.slice(b+1)];return!l||0>l.indexOf(" "+f.slice(k+ +1,b)+" ")?null:f.slice(k+1)},noConflict:function(){f.SecondLevelDomains===this&&(f.SecondLevelDomains=l);return this}};return g});(function(f,l){"object"===typeof exports?module.exports=l(require("./punycode"),require("./IPv6"),require("./SecondLevelDomains")):"function"===typeof define&&define.amd?define(["./punycode","./IPv6","./SecondLevelDomains"],l):f.URI=l(f.punycode,f.IPv6,f.SecondLevelDomains,f)})(this,function(f,l,g,m){function b(a,c){if(!(this instanceof b))return new b(a,c);void 0===a&&(a="undefined"!==typeof location?location.href+"":"");this.href(a);return void 0!==c?this.absoluteTo(c):this}function k(a){return a.replace(/([.*+?^=!:${}()|[\]\/\\])/g, +"\\$1")}function z(a){return void 0===a?"Undefined":String(Object.prototype.toString.call(a)).slice(8,-1)}function p(a){return"Array"===z(a)}function h(a,c){var d,b;if(p(c)){d=0;for(b=c.length;d]+|\(([^\s()<>]+|(\([^\s()<>]+\)))*\))+(?:\(([^\s()<>]+|(\([^\s()<>]+\)))*\)|[^\s`!()\[\]{};:'".,<>?\u00ab\u00bb\u201c\u201d\u2018\u2019]))/ig;b.findUri={start:/\b(?:([a-z][a-z0-9.+-]*:\/\/)|www\.)/gi,end:/[\s\r\n]|$/,trim:/[`!()\[\]{};:'".,<>?\u00ab\u00bb\u201c\u201d\u201e\u2018\u2019]+$/};b.defaultPorts={http:"80",https:"443",ftp:"21",gopher:"70",ws:"80",wss:"443"};b.invalid_hostname_characters= +/[^a-zA-Z0-9\.-]/;b.domAttributes={a:"href",blockquote:"cite",link:"href",base:"href",script:"src",form:"action",img:"src",area:"href",iframe:"src",embed:"src",source:"src",track:"src",input:"src",audio:"src",video:"src"};b.getDomAttribute=function(a){if(a&&a.nodeName){var c=a.nodeName.toLowerCase();return"input"===c&&"image"!==a.type?void 0:b.domAttributes[c]}};b.encode=D;b.decode=decodeURIComponent;b.iso8859=function(){b.encode=escape;b.decode=unescape};b.unicode=function(){b.encode=D;b.decode= +decodeURIComponent};b.characters={pathname:{encode:{expression:/%(24|26|2B|2C|3B|3D|3A|40)/ig,map:{"%24":"$","%26":"&","%2B":"+","%2C":",","%3B":";","%3D":"=","%3A":":","%40":"@"}},decode:{expression:/[\/\?#]/g,map:{"/":"%2F","?":"%3F","#":"%23"}}},reserved:{encode:{expression:/%(21|23|24|26|27|28|29|2A|2B|2C|2F|3A|3B|3D|3F|40|5B|5D)/ig,map:{"%3A":":","%2F":"/","%3F":"?","%23":"#","%5B":"[","%5D":"]","%40":"@","%21":"!","%24":"$","%26":"&","%27":"'","%28":"(","%29":")","%2A":"*","%2B":"+","%2C":",", +"%3B":";","%3D":"="}}}};b.encodeQuery=function(a,c){var d=b.encode(a+"");void 0===c&&(c=b.escapeQuerySpace);return c?d.replace(/%20/g,"+"):d};b.decodeQuery=function(a,c){a+="";void 0===c&&(c=b.escapeQuerySpace);try{return b.decode(c?a.replace(/\+/g,"%20"):a)}catch(d){return a}};b.recodePath=function(a){a=(a+"").split("/");for(var c=0,d=a.length;cb)return a.charAt(0)===c.charAt(0)&&"/"===a.charAt(0)?"/":"";if("/"!==a.charAt(b)||"/"!==c.charAt(b))b=a.substring(0,b).lastIndexOf("/");return a.substring(0,b+1)};b.withinString=function(a,c,d){d||(d={});var e=d.start||b.findUri.start,f=d.end||b.findUri.end,h=d.trim||b.findUri.trim,g=/[a-z0-9-]=["']?$/i;for(e.lastIndex=0;;){var r=e.exec(a);if(!r)break;r=r.index;if(d.ignoreHtml){var k= +a.slice(Math.max(r-3,0),r);if(k&&g.test(k))continue}var k=r+a.slice(r).search(f),m=a.slice(r,k).replace(h,"");d.ignore&&d.ignore.test(m)||(k=r+m.length,m=c(m,r,k,a),a=a.slice(0,r)+m+a.slice(k),e.lastIndex=r+m.length)}e.lastIndex=0;return a};b.ensureValidHostname=function(a){if(a.match(b.invalid_hostname_characters)){if(!f)throw new TypeError('Hostname "'+a+'" contains characters other than [A-Z0-9.-] and Punycode.js is not available');if(f.toASCII(a).match(b.invalid_hostname_characters))throw new TypeError('Hostname "'+ +a+'" contains characters other than [A-Z0-9.-]');}};b.noConflict=function(a){if(a)return a={URI:this.noConflict()},m.URITemplate&&"function"===typeof m.URITemplate.noConflict&&(a.URITemplate=m.URITemplate.noConflict()),m.IPv6&&"function"===typeof m.IPv6.noConflict&&(a.IPv6=m.IPv6.noConflict()),m.SecondLevelDomains&&"function"===typeof m.SecondLevelDomains.noConflict&&(a.SecondLevelDomains=m.SecondLevelDomains.noConflict()),a;m.URI===this&&(m.URI=n);return this};e.build=function(a){if(!0===a)this._deferred_build= +!0;else if(void 0===a||this._deferred_build)this._string=b.build(this._parts),this._deferred_build=!1;return this};e.clone=function(){return new b(this)};e.valueOf=e.toString=function(){return this.build(!1)._string};e.protocol=A("protocol");e.username=A("username");e.password=A("password");e.hostname=A("hostname");e.port=A("port");e.query=t("query","?");e.fragment=t("fragment","#");e.search=function(a,c){var d=this.query(a,c);return"string"===typeof d&&d.length?"?"+d:d};e.hash=function(a,c){var d= +this.fragment(a,c);return"string"===typeof d&&d.length?"#"+d:d};e.pathname=function(a,c){if(void 0===a||!0===a){var d=this._parts.path||(this._parts.hostname?"/":"");return a?b.decodePath(d):d}this._parts.path=a?b.recodePath(a):"/";this.build(!c);return this};e.path=e.pathname;e.href=function(a,c){var d;if(void 0===a)return this.toString();this._string="";this._parts=b._parts();var e=a instanceof b,f="object"===typeof a&&(a.hostname||a.path||a.pathname);a.nodeName&&(f=b.getDomAttribute(a),a=a[f]|| +"",f=!1);!e&&f&&void 0!==a.pathname&&(a=a.toString());if("string"===typeof a||a instanceof String)this._parts=b.parse(String(a),this._parts);else if(e||f)for(d in e=e?a._parts:a,e)v.call(this._parts,d)&&(this._parts[d]=e[d]);else throw new TypeError("invalid input");this.build(!c);return this};e.is=function(a){var c=!1,d=!1,e=!1,f=!1,h=!1,r=!1,k=!1,m=!this._parts.urn;this._parts.hostname&&(m=!1,d=b.ip4_expression.test(this._parts.hostname),e=b.ip6_expression.test(this._parts.hostname),c=d||e,h=(f= +!c)&&g&&g.has(this._parts.hostname),r=f&&b.idn_expression.test(this._parts.hostname),k=f&&b.punycode_expression.test(this._parts.hostname));switch(a.toLowerCase()){case "relative":return m;case "absolute":return!m;case "domain":case "name":return f;case "sld":return h;case "ip":return c;case "ip4":case "ipv4":case "inet4":return d;case "ip6":case "ipv6":case "inet6":return e;case "idn":return r;case "url":return!this._parts.urn;case "urn":return!!this._parts.urn;case "punycode":return k}return null}; +var E=e.protocol,F=e.port,G=e.hostname;e.protocol=function(a,c){if(void 0!==a&&a&&(a=a.replace(/:(\/\/)?$/,""),!a.match(b.protocol_expression)))throw new TypeError('Protocol "'+a+"\" contains characters other than [A-Z0-9.+-] or doesn't start with [A-Z]");return E.call(this,a,c)};e.scheme=e.protocol;e.port=function(a,c){if(this._parts.urn)return void 0===a?"":this;if(void 0!==a&&(0===a&&(a=null),a&&(a+="",":"===a.charAt(0)&&(a=a.substring(1)),a.match(/[^0-9]/))))throw new TypeError('Port "'+a+'" contains characters other than [0-9]'); +return F.call(this,a,c)};e.hostname=function(a,c){if(this._parts.urn)return void 0===a?"":this;if(void 0!==a){var d={};b.parseHost(a,d);a=d.hostname}return G.call(this,a,c)};e.host=function(a,c){if(this._parts.urn)return void 0===a?"":this;if(void 0===a)return this._parts.hostname?b.buildHost(this._parts):"";b.parseHost(a,this._parts);this.build(!c);return this};e.authority=function(a,c){if(this._parts.urn)return void 0===a?"":this;if(void 0===a)return this._parts.hostname?b.buildAuthority(this._parts): +"";b.parseAuthority(a,this._parts);this.build(!c);return this};e.userinfo=function(a,c){if(this._parts.urn)return void 0===a?"":this;if(void 0===a){if(!this._parts.username)return"";var d=b.buildUserinfo(this._parts);return d.substring(0,d.length-1)}"@"!==a[a.length-1]&&(a+="@");b.parseUserinfo(a,this._parts);this.build(!c);return this};e.resource=function(a,c){var d;if(void 0===a)return this.path()+this.search()+this.hash();d=b.parse(a);this._parts.path=d.path;this._parts.query=d.query;this._parts.fragment= +d.fragment;this.build(!c);return this};e.subdomain=function(a,c){if(this._parts.urn)return void 0===a?"":this;if(void 0===a){if(!this._parts.hostname||this.is("IP"))return"";var d=this._parts.hostname.length-this.domain().length-1;return this._parts.hostname.substring(0,d)||""}d=this._parts.hostname.length-this.domain().length;d=this._parts.hostname.substring(0,d);d=new RegExp("^"+k(d));a&&"."!==a.charAt(a.length-1)&&(a+=".");a&&b.ensureValidHostname(a);this._parts.hostname=this._parts.hostname.replace(d, +a);this.build(!c);return this};e.domain=function(a,c){if(this._parts.urn)return void 0===a?"":this;"boolean"===typeof a&&(c=a,a=void 0);if(void 0===a){if(!this._parts.hostname||this.is("IP"))return"";var d=this._parts.hostname.match(/\./g);if(d&&2>d.length)return this._parts.hostname;d=this._parts.hostname.length-this.tld(c).length-1;d=this._parts.hostname.lastIndexOf(".",d-1)+1;return this._parts.hostname.substring(d)||""}if(!a)throw new TypeError("cannot set domain empty");b.ensureValidHostname(a); +!this._parts.hostname||this.is("IP")?this._parts.hostname=a:(d=new RegExp(k(this.domain())+"$"),this._parts.hostname=this._parts.hostname.replace(d,a));this.build(!c);return this};e.tld=function(a,c){if(this._parts.urn)return void 0===a?"":this;"boolean"===typeof a&&(c=a,a=void 0);if(void 0===a){if(!this._parts.hostname||this.is("IP"))return"";var d=this._parts.hostname.lastIndexOf("."),d=this._parts.hostname.substring(d+1);return!0!==c&&g&&g.list[d.toLowerCase()]?g.get(this._parts.hostname)||d:d}if(a)if(a.match(/[^a-zA-Z0-9-]/))if(g&& +g.is(a))d=new RegExp(k(this.tld())+"$"),this._parts.hostname=this._parts.hostname.replace(d,a);else throw new TypeError('TLD "'+a+'" contains characters other than [A-Z0-9]');else{if(!this._parts.hostname||this.is("IP"))throw new ReferenceError("cannot set TLD on non-domain host");d=new RegExp(k(this.tld())+"$");this._parts.hostname=this._parts.hostname.replace(d,a)}else throw new TypeError("cannot set TLD empty");this.build(!c);return this};e.directory=function(a,c){if(this._parts.urn)return void 0=== +a?"":this;if(void 0===a||!0===a){if(!this._parts.path&&!this._parts.hostname)return"";if("/"===this._parts.path)return"/";var d=this._parts.path.length-this.filename().length-1,d=this._parts.path.substring(0,d)||(this._parts.hostname?"/":"");return a?b.decodePath(d):d}d=this._parts.path.length-this.filename().length;d=this._parts.path.substring(0,d);d=new RegExp("^"+k(d));this.is("relative")||(a||(a="/"),"/"!==a.charAt(0)&&(a="/"+a));a&&"/"!==a.charAt(a.length-1)&&(a+="/");a=b.recodePath(a);this._parts.path= +this._parts.path.replace(d,a);this.build(!c);return this};e.filename=function(a,c){if(this._parts.urn)return void 0===a?"":this;if(void 0===a||!0===a){if(!this._parts.path||"/"===this._parts.path)return"";var d=this._parts.path.lastIndexOf("/"),d=this._parts.path.substring(d+1);return a?b.decodePathSegment(d):d}d=!1;"/"===a.charAt(0)&&(a=a.substring(1));a.match(/\.?\//)&&(d=!0);var e=new RegExp(k(this.filename())+"$");a=b.recodePath(a);this._parts.path=this._parts.path.replace(e,a);d?this.normalizePath(c): +this.build(!c);return this};e.suffix=function(a,c){if(this._parts.urn)return void 0===a?"":this;if(void 0===a||!0===a){if(!this._parts.path||"/"===this._parts.path)return"";var d=this.filename(),e=d.lastIndexOf(".");if(-1===e)return"";d=d.substring(e+1);d=/^[a-z0-9%]+$/i.test(d)?d:"";return a?b.decodePathSegment(d):d}"."===a.charAt(0)&&(a=a.substring(1));if(d=this.suffix())e=a?new RegExp(k(d)+"$"):new RegExp(k("."+d)+"$");else{if(!a)return this;this._parts.path+="."+b.recodePath(a)}e&&(a=b.recodePath(a), +this._parts.path=this._parts.path.replace(e,a));this.build(!c);return this};e.segment=function(a,c,d){var b=this._parts.urn?":":"/",e=this.path(),f="/"===e.substring(0,1),e=e.split(b);void 0!==a&&"number"!==typeof a&&(d=c,c=a,a=void 0);if(void 0!==a&&"number"!==typeof a)throw Error('Bad segment "'+a+'", must be 0-based integer');f&&e.shift();0>a&&(a=Math.max(e.length+a,0));if(void 0===c)return void 0===a?e:e[a];if(null===a||void 0===e[a])if(p(c)){e=[];a=0;for(var h=c.length;a0||navigator.msMaxTouchPoints>0)&&d.bind("touchend",function(a){a.preventDefault(),a.target.click()})}}]),a.directive("ngFileDropAvailable",["$parse","$timeout",function(a,b){return function(c,d,e){if("draggable"in document.createElement("span")){var f=a(e.ngFileDropAvailable);b(function(){f(c)})}}}]),a.directive("ngFileDrop",["$parse","$timeout",function(a,b){return function(c,d,e){function f(a,b){if(b.isDirectory){var c=b.createReader();i++,c.readEntries(function(b){for(var c=0;c0&&j[0].webkitGetAsEntry)for(var k=0;k

      Our Story

      -

      Quay.io was originally created out of necessesity when we wanted to use Docker containers with our original IDE product. We were using Docker containers to host and isolate server processes invoked on behalf of our users and often running their code. We started by building the Docker image dynamically whenever we spun up a new host node. The image was monolithic. It was too large, took too long to build, and was hard to manage conflicts. It was everything that Docker wasn't supposed to be. When we decided to split it up into pre-built images and host them somewhere, we noticed that there wasn't a good place to host images securely. Determined to scratch our own itch, we built Quay.io, and officially launched it as an aside in our presentation to the Docker New York City Meetup on October 2nd, 2013.

      +

      Quay.io was originally created out of necessity when we wanted to use Docker containers with our original IDE product. We were using Docker containers to host and isolate server processes invoked on behalf of our users and often running their code. We started by building the Docker image dynamically whenever we spun up a new host node. The image was monolithic. It was too large, took too long to build, and was hard to manage conflicts. It was everything that Docker wasn't supposed to be. When we decided to split it up into pre-built images and host them somewhere, we noticed that there wasn't a good place to host images securely. Determined to scratch our own itch, we built Quay.io, and officially launched it as an aside in our presentation to the Docker New York City Meetup on October 2nd, 2013.

      After launch, our customers demanded that Quay.io become our main focus. They rely on us to make sure they can store and distribute their Docker images, and we understand that solemn responsibility. Our customers have been fantastic with giving us great feedback and suggestions.

      In August, 2014, Quay.io joined CoreOS to provide registry support for the enterprise. As ever, we are working as hard as we can to deliver on the promise and execute our vision of what a top notch Docker registry should be.

      diff --git a/static/partials/setup.html b/static/partials/setup.html new file mode 100644 index 000000000..dba8a2b33 --- /dev/null +++ b/static/partials/setup.html @@ -0,0 +1,299 @@ +
      +
      +
      +
      + + Enterprise Registry Setup +
      + +
      +
      + + + + + + + + + + + +
      Almost done!
      +
      Configure your Redis database and other settings below
      +
      + +
      +
      +
      +
      + + + \ No newline at end of file diff --git a/static/partials/super-user.html b/static/partials/super-user.html index 9b34cf159..a48052d9b 100644 --- a/static/partials/super-user.html +++ b/static/partials/super-user.html @@ -1,40 +1,83 @@ -
      -
      - This panel provides administrator access to super users of this installation of the registry. Super users can be managed in the configuration for this installation. -
      - -
      - -
      - +
      +
      +
      +
      + + +
      Container restart required!
      + Configuration changes have been made but the container hasn't been restarted yet. +
      +
      + + Enterprise Registry Management
      - -
      -
      +
      +
      + + + + + + + + + + + + + + + +
      + +
      + +
      +
      +
      + + +
      +
      + +
      + + + +
      + Select a service above to view its local logs + + +
      +
      +
      +
      +
      -
      +
      -
      +
      + current="systemUsage.usage" usage-title="Deployed Containers">
      @@ -51,46 +94,13 @@ You are nearing the number of allowed deployed repositories. It might be time to think about upgrading your subscription by contacting CoreOS Sales.
      -
      - -
      - -
      -
      - - -
      - -
      - - -
      - - -
      - -
      - - - - - - - - - - - - -
      UsernameE-mail addressTemporary Password
      {{ created_user.username }}{{ created_user.email }}{{ created_user.password }}
      -
      -
      + For more information: See Here. +
      -
      +
      {{ usersError }}
      @@ -103,105 +113,186 @@
      +
      + + class="user-row"> +
      Username E-mail address
      - + + + + + You + + + Superuser + + {{ current_user.username }} {{ current_user.email }} - - + + + Change Password + + + Send Recovery Email + + + Delete User + +
      +
      +
      +
      +
      + + -
      - - - - +
      + + + + + + + + diff --git a/storage/__init__.py b/storage/__init__.py index 4d1134d4b..7893343c2 100644 --- a/storage/__init__.py +++ b/storage/__init__.py @@ -11,6 +11,14 @@ STORAGE_DRIVER_CLASSES = { 'RadosGWStorage': RadosGWStorage, } +def get_storage_driver(storage_params): + """ Returns a storage driver class for the given storage configuration + (a pair of string name and a dict of parameters). """ + driver = storage_params[0] + parameters = storage_params[1] + driver_class = STORAGE_DRIVER_CLASSES.get(driver, FakeStorage) + return driver_class(**parameters) + class Storage(object): def __init__(self, app=None): @@ -23,12 +31,7 @@ class Storage(object): def init_app(self, app): storages = {} for location, storage_params in app.config.get('DISTRIBUTED_STORAGE_CONFIG').items(): - driver = storage_params[0] - parameters = storage_params[1] - - driver_class = STORAGE_DRIVER_CLASSES.get(driver, FakeStorage) - storage = driver_class(**parameters) - storages[location] = storage + storages[location] = get_storage_driver(storage_params) preference = app.config.get('DISTRIBUTED_STORAGE_PREFERENCE', None) if not preference: diff --git a/storage/basestorage.py b/storage/basestorage.py index 332a5d2ca..da297fcf1 100644 --- a/storage/basestorage.py +++ b/storage/basestorage.py @@ -54,6 +54,10 @@ class BaseStorage(StoragePaths): # Set the IO buffer to 64kB buffer_size = 64 * 1024 + def setup(self): + """ Called to perform any storage system setup. """ + pass + def get_direct_download_url(self, path, expires_in=60, requires_cors=False): return None diff --git a/storage/cloud.py b/storage/cloud.py index 06dd8a2a9..91dadfb3e 100644 --- a/storage/cloud.py +++ b/storage/cloud.py @@ -77,6 +77,13 @@ class _CloudStorage(BaseStorage): return path[1:] return path + def get_cloud_conn(self): + self._initialize_cloud_conn() + return self._cloud_conn + + def get_cloud_bucket(self): + return self._cloud_bucket + def get_content(self, path): self._initialize_cloud_conn() path = self._init_path(path) @@ -221,6 +228,25 @@ class S3Storage(_CloudStorage): connect_kwargs, upload_params, storage_path, s3_access_key, s3_secret_key, s3_bucket) + def setup(self): + self.get_cloud_bucket().set_cors_xml(""" + + + * + GET + 3000 + Authorization + + + * + PUT + 3000 + Content-Type + x-amz-acl + origin + + """) + class GoogleCloudStorage(_CloudStorage): def __init__(self, storage_path, access_key, secret_key, bucket_name): @@ -230,6 +256,24 @@ class GoogleCloudStorage(_CloudStorage): connect_kwargs, upload_params, storage_path, access_key, secret_key, bucket_name) + def setup(self): + self.get_cloud_bucket().set_cors_xml(""" + + + + * + + + GET + PUT + + + Content-Type + + 3000 + + """) + def stream_write(self, path, fp, content_type=None, content_encoding=None): # Minimum size of upload part size on S3 is 5MB self._initialize_cloud_conn() diff --git a/templates/base.html b/templates/base.html index 122a69614..3c92cfcf6 100644 --- a/templates/base.html +++ b/templates/base.html @@ -96,10 +96,9 @@ mixpanel.init("{{ mixpanel_key }}", { track_pageview : false, debug: {{ is_debug