+ 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
@@ -631,12 +522,90 @@
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+ Validating Configuration... Please Wait
+
+
+ Configuration Validation Failed
+
+
+ Configuration Validation Succeeded!
+
+
+ Configuration Changes Saved
+
+
+
+
+ Configuration Changes Saved
+
+
+
Your configuration changes have been saved and will be applied the next time the container is restarted.
+
+
+
+ It is highly recommended that you restart your container now and test these changes!
+
+
+
+
+
+
+
+
+
+
+ {{ serviceInfo.service.title }}
+
+
{{ serviceInfo.errorMessage }}
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
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/js/core-config-setup.js b/static/js/core-config-setup.js
index 1aff16ea9..d0d2bd8e6 100644
--- a/static/js/core-config-setup.js
+++ b/static/js/core-config-setup.js
@@ -10,8 +10,137 @@ angular.module("core-config-setup", ['angularFileUpload'])
'isActive': '=isActive'
},
controller: function($rootScope, $scope, $element, $timeout, ApiService) {
+ $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;
+ }}
+ ];
+
+ $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.config = null;
- $scope.mapped = {};
+ $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.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.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;
+
+ var data = {
+ 'config': $scope.config,
+ 'hostname': window.location.host
+ };
+
+ ApiService.scUpdateConfig(data).then(function(resp) {
+ $scope.savingConfiguration = false;
+ $scope.mapped.$hasChanges = false
+ }, ApiService.errorDisplay('Could not save configuration. Please report this error.'));
+ };
var githubSelector = function(key) {
return function(value) {
@@ -36,8 +165,8 @@ angular.module("core-config-setup", ['angularFileUpload'])
var current = config;
for (var i = 0; i < parts.length; ++i) {
var part = parts[i];
- if (!config[part]) { return null; }
- current = config[part];
+ if (!current[part]) { return null; }
+ current = current[part];
}
return current;
};
@@ -86,7 +215,36 @@ angular.module("core-config-setup", ['angularFileUpload'])
$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];
+
+ 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];
+ }
+ }
+ });
+
$scope.$watch('config', function(value) {
+ $scope.mapped['$hasChanges'] = true;
}, true);
$scope.$watch('isActive', function(value) {
@@ -95,6 +253,7 @@ angular.module("core-config-setup", ['angularFileUpload'])
ApiService.scGetConfig().then(function(resp) {
$scope.config = resp['config'];
initializeMappedLogic($scope.config);
+ $scope.mapped['$hasChanges'] = false;
});
});
}
@@ -376,9 +535,7 @@ angular.module("core-config-setup", ['angularFileUpload'])
'binding': '=binding'
},
controller: function($scope, $element) {
- $scope.$watch('items', function(items) {
- if (!items) { return; }
-
+ 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);
@@ -386,14 +543,45 @@ angular.module("core-config-setup", ['angularFileUpload'])
}
// If the last item is non-empty, add a new item.
- if (items[items.length - 1].value) {
+ 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) {
- $scope.items = [];
- $scope.items.push({'value': ''});
+ if (!binding) { return; }
+
+ 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;
+ }
});
}
};
@@ -416,6 +604,7 @@ angular.module("core-config-setup", ['angularFileUpload'])
$scope.value = null;
var updateBinding = function() {
+ if ($scope.value == null) { return; }
var value = $scope.value || '';
switch ($scope.kind) {
diff --git a/static/js/core-ui.js b/static/js/core-ui.js
index 2cd547f4d..9a42fd8dc 100644
--- a/static/js/core-ui.js
+++ b/static/js/core-ui.js
@@ -175,6 +175,49 @@ angular.module("core-ui", [])
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);
+ $internval.stop(stop);
+ });
+ }
+ };
+ return directiveDefinitionObject;
+
+ })
+
.directive('corTab', function() {
var directiveDefinitionObject = {
priority: 4,
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/util/config/__init__.py b/util/config/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/util/configutil.py b/util/config/configutil.py
similarity index 100%
rename from util/configutil.py
rename to util/config/configutil.py
diff --git a/util/config/validator.py b/util/config/validator.py
new file mode 100644
index 000000000..16211c254
--- /dev/null
+++ b/util/config/validator.py
@@ -0,0 +1,122 @@
+import redis
+import os
+import json
+import ldap
+
+from data.users import LDAPConnection
+from flask import Flask
+from flask.ext.mail import Mail, Message
+from data.database import validate_database_url, User
+from storage import get_storage_driver
+from app import app, OVERRIDE_CONFIG_DIRECTORY
+from auth.auth_context import get_authenticated_user
+from util.oauth import GoogleOAuthConfig, GithubOAuthConfig
+
+SSL_FILENAMES = ['ssl.cert', 'ssl.key']
+
+def validate_service_for_config(service, config):
+ """ Attempts to validate the configuration for the given service. """
+ if not service in _VALIDATORS:
+ return {
+ 'status': False
+ }
+
+ try:
+ _VALIDATORS[service](config)
+ return {
+ 'status': True
+ }
+ except Exception as ex:
+ return {
+ 'status': False,
+ 'reason': str(ex)
+ }
+
+def _validate_database(config):
+ """ Validates connecting to the database. """
+ validate_database_url(config['DB_URI'])
+
+def _validate_redis(config):
+ """ Validates connecting to redis. """
+ redis_config = config['BUILDLOGS_REDIS']
+ client = redis.StrictRedis(socket_connect_timeout=5, **redis_config)
+ client.ping()
+
+def _validate_registry_storage(config):
+ """ Validates registry storage. """
+ parameters = config.get('DISTRIBUTED_STORAGE_CONFIG', {}).get('local', ['LocalStorage', {}])
+ try:
+ driver = get_storage_driver(parameters)
+ except TypeError:
+ raise Exception('Missing required storage configuration parameter(s)')
+
+ # Put and remove a temporary file.
+ driver.put_content('_verify', 'testing 123')
+ driver.remove('_verify')
+
+def _validate_mailing(config):
+ """ Validates sending email. """
+ test_app = Flask("mail-test-app")
+ test_app.config.update(config)
+ test_app.config.update({
+ 'MAIL_FAIL_SILENTLY': False,
+ 'TESTING': False
+ })
+
+ test_mail = Mail(test_app)
+ test_msg = Message("Test e-mail from %s" % app.config['REGISTRY_TITLE'])
+ test_msg.add_recipient(get_authenticated_user().email)
+ test_mail.send(test_msg)
+
+def _validate_github_login(config):
+ """ Validates the OAuth credentials and API endpoint for Github Login. """
+ client = app.config['HTTPCLIENT']
+ oauth = GithubOAuthConfig(config, 'GITHUB_LOGIN_CONFIG')
+ endpoint = oauth.authorize_endpoint()
+ # TODO: this
+
+
+def _validate_ssl(config):
+ """ Validates the SSL configuration (if enabled). """
+ if config.get('PREFERRED_URL_SCHEME', 'http') != 'https':
+ return
+
+ for filename in SSL_FILENAMES:
+ if not os.path.exists(os.path.join(OVERRIDE_CONFIG_DIRECTORY, filename)):
+ raise Exception('Missing required SSL file: %s' % filename)
+
+
+def _validate_ldap(config):
+ """ Validates the LDAP connection. """
+ if config.get('AUTHENTICATION_TYPE', 'Database') != 'LDAP':
+ return
+
+ # Note: raises ldap.INVALID_CREDENTIALS on failure
+ admin_dn = config.get('LDAP_ADMIN_DN')
+ admin_passwd = config.get('LDAP_ADMIN_PASSWD')
+
+ if not admin_dn:
+ raise Exception('Missing Admin DN for LDAP configuration')
+
+ if not admin_passwd:
+ raise Exception('Missing Admin Password for LDAP configuration')
+
+ ldap_uri = config.get('LDAP_URI', 'ldap://localhost')
+
+ try:
+ with LDAPConnection(ldap_uri, admin_dn, admin_passwd):
+ pass
+ except ldap.LDAPError as ex:
+ values = ex.args[0] if ex.args else {}
+ raise Exception(values.get('desc', 'Unknown error'))
+
+
+_VALIDATORS = {
+ 'database': _validate_database,
+ 'redis': _validate_redis,
+ 'registry-storage': _validate_registry_storage,
+ 'mail': _validate_mailing,
+ 'github-login': _validate_github_login,
+ 'ssl': _validate_ssl,
+ 'ldap': _validate_ldap,
+}
\ No newline at end of file
diff --git a/util/oauth.py b/util/oauth.py
index e0d38d395..65af5cd13 100644
--- a/util/oauth.py
+++ b/util/oauth.py
@@ -1,9 +1,9 @@
import urlparse
class OAuthConfig(object):
- def __init__(self, app, key_name):
+ def __init__(self, config, key_name):
self.key_name = key_name
- self.config = app.config.get(key_name) or {}
+ self.config = config.get(key_name) or {}
def service_name(self):
raise NotImplementedError
@@ -23,6 +23,9 @@ class OAuthConfig(object):
def client_secret(self):
return self.config.get('CLIENT_SECRET')
+ def basic_scope(self):
+ raise NotImplementedError
+
def _get_url(self, endpoint, *args):
for arg in args:
endpoint = urlparse.urljoin(endpoint, arg)
@@ -31,8 +34,8 @@ class OAuthConfig(object):
class GithubOAuthConfig(OAuthConfig):
- def __init__(self, app, key_name):
- super(GithubOAuthConfig, self).__init__(app, key_name)
+ def __init__(self, config, key_name):
+ super(GithubOAuthConfig, self).__init__(config, key_name)
def service_name(self):
return 'GitHub'
@@ -43,6 +46,9 @@ class GithubOAuthConfig(OAuthConfig):
endpoint = endpoint + '/'
return endpoint
+ def basic_scope(self):
+ return 'user:email'
+
def authorize_endpoint(self):
return self._get_url(self._endpoint(), '/login/oauth/authorize') + '?'
@@ -73,12 +79,15 @@ class GithubOAuthConfig(OAuthConfig):
class GoogleOAuthConfig(OAuthConfig):
- def __init__(self, app, key_name):
- super(GoogleOAuthConfig, self).__init__(app, key_name)
+ def __init__(self, config, key_name):
+ super(GoogleOAuthConfig, self).__init__(config, key_name)
def service_name(self):
return 'Google'
+ def basic_scope(self):
+ return 'openid email'
+
def authorize_endpoint(self):
return 'https://accounts.google.com/o/oauth2/auth?response_type=code&'