From 5000b1621cee4052c0bad68f981913c8cbb975e7 Mon Sep 17 00:00:00 2001
From: Silas Sewell
Date: Mon, 26 Oct 2015 16:06:05 -0700
Subject: [PATCH] superuser: add storage replication config
---
Dockerfile | 2 +-
boot.py | 20 +++
conf/init/zz_boot.sh | 3 +
conf/init/zz_release.sh | 8 -
data/model/image.py | 16 ++
endpoints/api/suconfig.py | 6 +-
release.py | 12 --
static/css/pages/superuser.css | 28 ++-
static/css/quay.css | 2 +-
.../directives/config/config-setup-tool.html | 130 +++++++++-----
static/js/core-config-setup.js | 167 ++++++++++++++++--
storage/distributedstorage.py | 7 +-
util/config/configutil.py | 8 +-
util/config/database.py | 9 +
util/config/validator.py | 45 +++--
15 files changed, 357 insertions(+), 106 deletions(-)
create mode 100644 boot.py
create mode 100755 conf/init/zz_boot.sh
delete mode 100755 conf/init/zz_release.sh
create mode 100644 util/config/database.py
diff --git a/Dockerfile b/Dockerfile
index 5a757aa21..737ae4934 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -43,7 +43,7 @@ ADD conf/init/doupdatelimits.sh /etc/my_init.d/
ADD conf/init/copy_syslog_config.sh /etc/my_init.d/
ADD conf/init/runmigration.sh /etc/my_init.d/
ADD conf/init/syslog-ng.conf /etc/syslog-ng/
-ADD conf/init/zz_release.sh /etc/my_init.d/
+ADD conf/init/zz_boot.sh /etc/my_init.d/
ADD conf/init/service/ /etc/service/
diff --git a/boot.py b/boot.py
new file mode 100644
index 000000000..f78ad6ab8
--- /dev/null
+++ b/boot.py
@@ -0,0 +1,20 @@
+#!/usr/bin/env python
+
+import release
+
+from app import app
+from data.model.release import set_region_release
+from util.config.database import sync_database_with_config
+
+
+def main():
+ if app.config.get('SETUP_COMPLETE', False):
+ sync_database_with_config(app.config)
+
+ # Record deploy
+ if release.REGION and release.GIT_HEAD:
+ set_region_release(release.SERVICE, release.REGION, release.GIT_HEAD)
+
+
+if __name__ == '__main__':
+ main()
diff --git a/conf/init/zz_boot.sh b/conf/init/zz_boot.sh
new file mode 100755
index 000000000..ab760266b
--- /dev/null
+++ b/conf/init/zz_boot.sh
@@ -0,0 +1,3 @@
+#!/bin/bash
+
+/venv/bin/python /boot.py
diff --git a/conf/init/zz_release.sh b/conf/init/zz_release.sh
deleted file mode 100755
index 152494cff..000000000
--- a/conf/init/zz_release.sh
+++ /dev/null
@@ -1,8 +0,0 @@
-#!/bin/bash
-set -e
-
-source venv/bin/activate
-
-export PYTHONPATH=.
-
-python /release.py
diff --git a/data/model/image.py b/data/model/image.py
index 078875417..f084f2662 100644
--- a/data/model/image.py
+++ b/data/model/image.py
@@ -356,3 +356,19 @@ def get_image(repo, dockerfile_id):
return Image.get(Image.docker_image_id == dockerfile_id, Image.repository == repo)
except Image.DoesNotExist:
return None
+
+
+def ensure_image_locations(*names):
+ with db_transaction():
+ locations = ImageStorageLocation.select().where(ImageStorageLocation.name << names)
+
+ insert_names = list(names)
+
+ for location in locations:
+ insert_names.remove(location.name)
+
+ if not insert_names:
+ return
+
+ data = [{'name': name} for name in insert_names]
+ ImageStorageLocation.insert_many(data).execute()
diff --git a/endpoints/api/suconfig.py b/endpoints/api/suconfig.py
index aaea5a309..5a5cd9346 100644
--- a/endpoints/api/suconfig.py
+++ b/endpoints/api/suconfig.py
@@ -16,6 +16,7 @@ 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.database import sync_database_with_config
from util.config.validator import validate_service_for_config, CONFIG_FILENAMES
from data.runmigration import run_alembic_migration
from data.users import get_federated_service_name
@@ -216,6 +217,9 @@ class SuperUserConfig(ApiResource):
current_user = get_authenticated_user()
model.user.confirm_attached_federated_login(current_user, service_name)
+ # Ensure database is up-to-date with config
+ sync_database_with_config(config_object)
+
return {
'exists': True,
'config': config_object
@@ -373,4 +377,4 @@ class SuperUserConfigValidate(ApiResource):
config = request.get_json()['config']
return validate_service_for_config(service, config, request.get_json().get('password', ''))
- abort(403)
\ No newline at end of file
+ abort(403)
diff --git a/release.py b/release.py
index 91a46f796..a0439d9a8 100644
--- a/release.py
+++ b/release.py
@@ -12,15 +12,3 @@ REGION = os.environ.get('QUAY_REGION')
if os.path.isfile(_GIT_HEAD_PATH):
with open(_GIT_HEAD_PATH) as f:
GIT_HEAD = f.read().strip()
-
-
-def main():
- from app import app
- from data.model.release import set_region_release
-
- if REGION and GIT_HEAD:
- set_region_release(SERVICE, REGION, GIT_HEAD)
-
-
-if __name__ == '__main__':
- main()
diff --git a/static/css/pages/superuser.css b/static/css/pages/superuser.css
index 380e31879..d1eae17bd 100644
--- a/static/css/pages/superuser.css
+++ b/static/css/pages/superuser.css
@@ -22,4 +22,30 @@
.super-user .user-row.disabled .avatar {
-webkit-filter: grayscale(100%);
-}
\ No newline at end of file
+}
+
+.super-user td .co-alert {
+ margin: 16px 0 0 0;
+}
+
+.super-user .add-storage-link {
+ margin-top: 5px;
+}
+
+.super-user .storage-config {
+ border-bottom: 1px solid #eee;
+ padding: 0 0 10px 0;
+ margin: 10px 0 0 0;
+}
+
+.super-user .last {
+ border-bottom: none;
+}
+
+.super-user .feature-storage-replication {
+ margin: 15px 0 10px 0;
+}
+
+.super-user .input-util {
+ margin-top: 10px;
+}
diff --git a/static/css/quay.css b/static/css/quay.css
index bd8e3873a..143496034 100644
--- a/static/css/quay.css
+++ b/static/css/quay.css
@@ -1363,7 +1363,7 @@ i.toggle-icon:hover {
form input.ng-invalid.ng-dirty,
*[ng-form] input.ng-invalid.ng-dirty {
- background-color: #FDD7D9;
+ background-color: #FDD7D9 !important;
}
form input.ng-valid.ng-dirty,
diff --git a/static/directives/config/config-setup-tool.html b/static/directives/config/config-setup-tool.html
index 9edbffd75..a36d08416 100644
--- a/static/directives/config/config-setup-tool.html
+++ b/static/directives/config/config-setup-tool.html
@@ -194,54 +194,94 @@
A remote storage system is required for high-avaliability systems.
-
-
- Storage Engine: |
-
-
- |
-
+
+
+
+
+
If enabled, replicates storage to other regions.
+
-
-
- {{ field.title }}: |
-
-
-
-
-
-
-
-
- |
+
+
+
+
+ {{ field.title }}: |
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ field.help_text }}
+
+
+ See Documentation for more information
+
+ |
+
+
+
+
+
diff --git a/static/js/core-config-setup.js b/static/js/core-config-setup.js
index e3d5c3483..13b49b865 100644
--- a/static/js/core-config-setup.js
+++ b/static/js/core-config-setup.js
@@ -185,12 +185,17 @@ angular.module("core-config-setup", ['angularFileUpload'])
$scope.checkValidateAndSave = function() {
if ($scope.configform.$valid) {
+ saveStorageConfig();
$scope.validateAndSave();
return;
}
- $element.find("input.ng-invalid:first")[0].scrollIntoView();
- $element.find("input.ng-invalid:first").focus();
+ var query = $element.find("input.ng-invalid:first");
+
+ if (query && query.length) {
+ query[0].scrollIntoView();
+ query.focus();
+ }
};
$scope.validateAndSave = function() {
@@ -277,6 +282,99 @@ angular.module("core-config-setup", ['angularFileUpload'])
}, ApiService.errorDisplay('Could not save configuration. Please report this error.'));
};
+ // Convert storage config to an array
+ var initializeStorageConfig = function($scope) {
+ var config = $scope.config.DISTRIBUTED_STORAGE_CONFIG || {};
+ var defaultLocations = $scope.config.DISTRIBUTED_STORAGE_DEFAULT_LOCATIONS || [];
+ var preference = $scope.config.DISTRIBUTED_STORAGE_PREFERENCE || [];
+
+ $scope.serverStorageConfig = angular.copy(config);
+ $scope.storageConfig = [];
+
+ Object.keys(config).forEach(function(location) {
+ $scope.storageConfig.push({
+ location: location,
+ defaultLocation: defaultLocations.indexOf(location) >= 0,
+ data: angular.copy(config[location]),
+ error: {},
+ });
+ });
+
+ if (!$scope.storageConfig.length) {
+ $scope.addStorageConfig('default');
+ return;
+ }
+
+ // match DISTRIBUTED_STORAGE_PREFERENCE order first, remaining are
+ // ordered by unicode point value
+ $scope.storageConfig.sort(function(a, b) {
+ var indexA = preference.indexOf(a.location);
+ var indexB = preference.indexOf(b.location);
+
+ if (indexA > -1 && indexB > -1) return indexA < indexB ? -1 : 1;
+ if (indexA > -1) return -1;
+ if (indexB > -1) return 1;
+
+ return a.location < b.location ? -1 : 1;
+ });
+ };
+
+ $scope.allowChangeLocationStorageConfig = function(location) {
+ if (!$scope.serverStorageConfig[location]) { return true };
+
+ // allow user to change location ID if another exists with the same ID
+ return $scope.storageConfig.filter(function(sc) {
+ return sc.location === location;
+ }).length >= 2;
+ };
+
+ $scope.allowRemoveStorageConfig = function(location) {
+ return $scope.storageConfig.length > 1 && $scope.allowChangeLocationStorageConfig(location);
+ };
+
+ $scope.canAddStorageConfig = function() {
+ return $scope.config &&
+ $scope.config.FEATURE_STORAGE_REPLICATION &&
+ $scope.storageConfig &&
+ (!$scope.storageConfig.length || $scope.storageConfig.length < 10);
+ };
+
+ $scope.addStorageConfig = function(location) {
+ var storageType = 'LocalStorage';
+
+ // Use last storage type by default
+ if ($scope.storageConfig.length) {
+ storageType = $scope.storageConfig[$scope.storageConfig.length-1].data[0];
+ }
+
+ $scope.storageConfig.push({
+ location: location || '',
+ defaultLocation: false,
+ data: [storageType, {}],
+ error: {},
+ });
+ };
+
+ $scope.removeStorageConfig = function(sc) {
+ $scope.storageConfig.splice($scope.storageConfig.indexOf(sc), 1);
+ };
+
+ var saveStorageConfig = function() {
+ var config = {};
+ var defaultLocations = [];
+ var preference = [];
+
+ $scope.storageConfig.forEach(function(sc) {
+ config[sc.location] = sc.data;
+ if (sc.defaultLocation) defaultLocations.push(sc.location);
+ preference.push(sc.location);
+ });
+
+ $scope.config.DISTRIBUTED_STORAGE_CONFIG = config;
+ $scope.config.DISTRIBUTED_STORAGE_DEFAULT_LOCATIONS = defaultLocations;
+ $scope.config.DISTRIBUTED_STORAGE_PREFERENCE = preference;
+ };
+
var gitlabSelector = function(key) {
return function(value) {
if (!value || !$scope.config) { return; }
@@ -378,18 +476,11 @@ 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];
+ // Remove extra extra fields (which are not allowed) from storage config.
+ var updateFields = function(sc) {
+ var type = sc.data[0];
+ var configObject = sc.data[1];
+ var allowedFields = $scope.STORAGE_CONFIG_FIELDS[type];
// Remove any fields not allowed.
for (var fieldName in configObject) {
@@ -412,8 +503,53 @@ angular.module("core-config-setup", ['angularFileUpload'])
configObject[allowedFields[i].name] = configObject[allowedFields[i].name] || false;
}
}
+ };
+
+ // Validate and update storage config on update.
+ var refreshStorageConfig = function() {
+ if (!$scope.config || !$scope.storageConfig) return;
+
+ var locationCounts = {};
+ var errors = [];
+ var valid = true;
+
+ $scope.storageConfig.forEach(function(sc) {
+ // remove extra fields from storage config
+ updateFields(sc);
+
+ if (!locationCounts[sc.location]) locationCounts[sc.location] = 0;
+ locationCounts[sc.location]++;
+ });
+
+ // validate storage config
+ $scope.storageConfig.forEach(function(sc) {
+ var error = {};
+
+ if ($scope.config.FEATURE_STORAGE_REPLICATION && sc.data[0] === 'LocalStorage') {
+ error.engine = 'Replication to a locally mounted directory is unsupported as it is only accessible on a single machine.';
+ valid = false;
+ }
+
+ if (locationCounts[sc.location] > 1) {
+ error.location = 'Location ID must be unique.';
+ valid = false;
+ }
+
+ errors.push(error);
+ });
+
+ $scope.storageConfigError = errors;
+ $scope.configform.$setValidity('storageConfig', valid);
+ };
+
+ $scope.$watch('config.FEATURE_STORAGE_REPLICATION', function() {
+ refreshStorageConfig();
});
+ $scope.$watch('storageConfig', function() {
+ refreshStorageConfig();
+ }, true);
+
$scope.$watch('config', function(value) {
$scope.mapped['$hasChanges'] = true;
}, true);
@@ -424,6 +560,7 @@ angular.module("core-config-setup", ['angularFileUpload'])
ApiService.scGetConfig().then(function(resp) {
$scope.config = resp['config'] || {};
initializeMappedLogic($scope.config);
+ initializeStorageConfig($scope);
$scope.mapped['$hasChanges'] = false;
}, ApiService.errorDisplay('Could not load config'));
});
@@ -919,4 +1056,4 @@ angular.module("core-config-setup", ['angularFileUpload'])
}
};
return directiveDefinitionObject;
- });
\ No newline at end of file
+ });
diff --git a/storage/distributedstorage.py b/storage/distributedstorage.py
index 49f32c559..55afec293 100644
--- a/storage/distributedstorage.py
+++ b/storage/distributedstorage.py
@@ -16,6 +16,7 @@ def _location_aware(unbound_func):
for preferred in self.preferred_locations:
if preferred in locations:
storage = self._storages[preferred]
+ break
if not storage:
storage = self._storages[random.sample(locations, 1)[0]]
@@ -26,10 +27,10 @@ def _location_aware(unbound_func):
class DistributedStorage(StoragePaths):
- def __init__(self, storages, preferred_locations=[], default_locations=[]):
+ def __init__(self, storages, preferred_locations=None, default_locations=None):
self._storages = dict(storages)
- self.preferred_locations = list(preferred_locations)
- self.default_locations = list(default_locations)
+ self.preferred_locations = list(preferred_locations or [])
+ self.default_locations = list(default_locations or [])
@property
def locations(self):
diff --git a/util/config/configutil.py b/util/config/configutil.py
index 2a007a794..5b9bc1dcb 100644
--- a/util/config/configutil.py
+++ b/util/config/configutil.py
@@ -32,15 +32,15 @@ def add_enterprise_config_defaults(config_obj, current_secret_key, hostname):
# Default storage configuration.
if not 'DISTRIBUTED_STORAGE_CONFIG' in config_obj:
- config_obj['DISTRIBUTED_STORAGE_PREFERENCE'] = ['local']
+ config_obj['DISTRIBUTED_STORAGE_PREFERENCE'] = ['default']
config_obj['DISTRIBUTED_STORAGE_CONFIG'] = {
- 'local': ['LocalStorage', {'storage_path': '/datastorage/registry'}]
+ 'default': ['LocalStorage', {'storage_path': '/datastorage/registry'}]
}
- config_obj['USERFILES_LOCATION'] = 'local'
+ config_obj['USERFILES_LOCATION'] = 'default'
config_obj['USERFILES_PATH'] = 'userfiles/'
- config_obj['LOG_ARCHIVE_LOCATION'] = 'local'
+ config_obj['LOG_ARCHIVE_LOCATION'] = 'default'
if not 'SERVER_HOSTNAME' in config_obj:
config_obj['SERVER_HOSTNAME'] = hostname
diff --git a/util/config/database.py b/util/config/database.py
new file mode 100644
index 000000000..ea04dc5dc
--- /dev/null
+++ b/util/config/database.py
@@ -0,0 +1,9 @@
+from data import model
+
+
+def sync_database_with_config(config):
+ """ This ensures all implicitly required reference table entries exist in the database. """
+
+ location_names = config.get('DISTRIBUTED_STORAGE_CONFIG', {}).keys()
+ if location_names:
+ model.image.ensure_image_locations(*location_names)
diff --git a/util/config/validator.py b/util/config/validator.py
index d4dc1cd39..1e8846646 100644
--- a/util/config/validator.py
+++ b/util/config/validator.py
@@ -30,12 +30,18 @@ JWT_FILENAMES = ['jwt-authn.cert']
CONFIG_FILENAMES = SSL_FILENAMES + DB_SSL_FILENAMES + JWT_FILENAMES
-def get_storage_provider(config):
- parameters = config.get('DISTRIBUTED_STORAGE_CONFIG', {}).get('local', ['LocalStorage', {}])
+def get_storage_providers(config):
+ storage_config = config.get('DISTRIBUTED_STORAGE_CONFIG', {})
+
+ drivers = {}
+
try:
- return get_storage_driver(parameters)
+ for name, parameters in storage_config.items():
+ drivers[name] = (parameters[0], get_storage_driver(parameters))
except TypeError:
- raise Exception('Missing required storage configuration parameter(s)')
+ raise Exception('Missing required storage configuration parameter(s): %s' % name)
+
+ return drivers
def validate_service_for_config(service, config, password=None):
""" Attempts to validate the configuration for the given service. """
@@ -80,20 +86,29 @@ def _validate_redis(config, _):
def _validate_registry_storage(config, _):
""" Validates registry storage. """
- driver = get_storage_provider(config)
+ replication_enabled = config.get('FEATURE_STORAGE_REPLICATION', False)
- # Run custom validation on the driver.
- driver.validate(app.config['HTTPCLIENT'])
+ providers = get_storage_providers(config).items()
- # Put and remove a temporary file to make sure the normal storage paths work.
- driver.put_content('_verify', 'testing 123')
- driver.remove('_verify')
+ if not providers:
+ raise Exception('Storage configuration required')
- # Run setup on the driver if the read/write succeeded.
- try:
- driver.setup()
- except Exception as ex:
- raise Exception('Could not prepare storage: %s' % str(ex))
+ for name, (storage_type, driver) in providers:
+ try:
+ if replication_enabled and storage_type == 'LocalStorage':
+ raise Exception('Locally mounted directory not supported with storage replication')
+
+ # Run custom validation on the driver.
+ driver.validate(app.config['HTTPCLIENT'])
+
+ # Put and remove a temporary file to make sure the normal storage paths work.
+ driver.put_content('_verify', 'testing 123')
+ driver.remove('_verify')
+
+ # Run setup on the driver if the read/write succeeded.
+ driver.setup()
+ except Exception as ex:
+ raise Exception('Invalid storage configuration: %s: %s' % (name, str(ex)))
def _validate_mailing(config, _):