This commit is contained in:
Joseph Schorr 2015-01-04 14:38:41 -05:00
parent 77278f0391
commit 1bf25f25c1
14 changed files with 942 additions and 336 deletions

22
app.py
View file

@ -1,7 +1,6 @@
import logging
import os
import json
import yaml
from flask import Flask as BaseFlask, Config as BaseConfig, request, Request
from flask.ext.principal import Principal
@ -20,6 +19,7 @@ from util.exceptionlog import Sentry
from util.queuemetrics import QueueMetrics
from util.names import urn_generator
from util.oauth import GoogleOAuthConfig, GithubOAuthConfig
from util.configutil import import_yaml
from data.billing import Billing
from data.buildlogs import BuildLogs
from data.archivedlogs import LogArchive
@ -32,18 +32,7 @@ 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]
import_yaml(self, config_file)
class Flask(BaseFlask):
""" Extends the Flask class to implement our custom Config class. """
@ -53,11 +42,12 @@ class Flask(BaseFlask):
return Config(root_path, self.default_config)
OVERRIDE_CONFIG_YAML_FILENAME = 'conf/stack/config.yaml'
OVERRIDE_CONFIG_PY_FILENAME = 'conf/stack/config.py'
OVERRIDE_CONFIG_DIRECTORY = 'conf/stack/'
OVERRIDE_CONFIG_YAML_FILENAME = OVERRIDE_CONFIG_DIRECTORY + 'config.yaml'
OVERRIDE_CONFIG_PY_FILENAME = OVERRIDE_CONFIG_DIRECTORY + 'config.py'
OVERRIDE_CONFIG_KEY = 'QUAY_OVERRIDE_CONFIG'
LICENSE_FILENAME = 'conf/stack/license.enc'
LICENSE_FILENAME = OVERRIDE_CONFIG_DIRECTORY + 'license.enc'
app = Flask(__name__)

View file

@ -90,6 +90,9 @@ class QuayDeferredPermissionUser(Identity):
logger.debug('Loading user permissions after deferring.')
user_object = model.get_user_by_uuid(self.id)
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', [])):

View file

@ -48,8 +48,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 +133,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 +151,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
@ -194,4 +195,6 @@ class DefaultConfig(object):
SYSTEM_SERVICES_PATH = "conf/init/"
# Services that should not be shown in the logs view.
SYSTEM_SERVICE_BLACKLIST = ['tutumdocker', 'dockerfilebuild']
SYSTEM_SERVICE_BLACKLIST = ['tutumdocker', 'dockerfilebuild']
DEBUGGING = True

View file

@ -70,6 +70,11 @@ read_slave = Proxy()
db_random_func = CallableProxy()
def validate_database_url(url):
driver = _db_from_url(url, {})
driver.connect()
driver.close()
def _db_from_url(url, db_kwargs):
parsed_url = make_url(url)

View file

@ -385,8 +385,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

273
endpoints/api/suconfig.py Normal file
View file

@ -0,0 +1,273 @@
import logging
import os
import json
from flask import abort
from endpoints.api import (ApiResource, nickname, resource, internal_only, show_if, hide_if,
require_fresh_login, request, validate_json_request)
from endpoints.common import common_login
from app import app, OVERRIDE_CONFIG_YAML_FILENAME, OVERRIDE_CONFIG_DIRECTORY
from data import model
from data.database import User, validate_database_url
from auth.permissions import SuperUserPermission
from auth.auth_context import get_authenticated_user
from util.configutil import (import_yaml, export_yaml, add_enterprise_config_defaults,
set_config_value)
import features
logger = logging.getLogger(__name__)
CONFIG_FILE_WHITELIST = ['ssl.key', 'ssl.cert']
def database_is_valid():
try:
User.select().limit(1)
return True
except:
return False
def database_has_users():
return bool(list(User.select().limit(1)))
@resource('/v1/superuser/registrystatus')
@internal_only
@show_if(features.SUPER_USERS)
@hide_if(features.BILLING) # Make sure it is never allowed in prod.
class SuperUserRegistryStatus(ApiResource):
""" Resource for determining the status of the registry, such as if config exists,
if a database is configured, and if it has any defined users.
"""
@nickname('scRegistryStatus')
def get(self):
""" Returns whether a valid configuration, database and users exist. """
current_user = get_authenticated_user()
return {
'dir_exists': os.path.exists(OVERRIDE_CONFIG_DIRECTORY),
'file_exists': os.path.exists(OVERRIDE_CONFIG_YAML_FILENAME),
'is_testing': app.config['TESTING'],
'valid_db': database_is_valid(),
'ready': current_user and current_user.username in app.config['SUPER_USERS']
}
@resource('/v1/superuser/config')
@internal_only
@show_if(features.SUPER_USERS)
@hide_if(features.BILLING) # Make sure it is never allowed in prod.
class SuperUserGetConfig(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'
],
'properties': {
'config': {
'type': 'object'
}
},
},
}
@require_fresh_login
@nickname('scGetConfig')
def get(self):
""" Returns the currently defined configuration, if any. """
if SuperUserPermission().can():
config_object = {}
try:
import_yaml(config_object, OVERRIDE_CONFIG_YAML_FILENAME)
except Exception:
config_object = None
return {
'config': config_object
}
abort(403)
@nickname('scUpdateConfig')
@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 os.path.exists(OVERRIDE_CONFIG_YAML_FILENAME) or SuperUserPermission().can():
config_object = request.get_json()['config']
# Add any enterprise defaults missing from the config.
add_enterprise_config_defaults(config_object)
# Write the configuration changes to the YAML file.
export_yaml(config_object, OVERRIDE_CONFIG_YAML_FILENAME)
return {
'exists': True,
'config': config_object
}
abort(403)
@resource('/v1/superuser/config/file/<filename>')
@internal_only
@show_if(features.SUPER_USERS)
@hide_if(features.BILLING) # Make sure it is never allowed in prod.
class SuperUserConfigFile(ApiResource):
""" Resource for fetching the status of config files and overriding them. """
@nickname('scConfigFileExists')
def get(self, filename):
""" Returns whether the configuration file with the given name exists. """
if not filename in CONFIG_FILE_WHITELIST:
abort(404)
if SuperUserPermission().can():
return {
'exists': os.path.exists(os.path.join(OVERRIDE_CONFIG_DIRECTORY, filename))
}
abort(403)
@nickname('scUpdateConfigFile')
def post(self, filename):
""" Updates the configuration file with the given name. """
if not filename in CONFIG_FILE_WHITELIST:
abort(404)
if SuperUserPermission().can():
uploaded_file = request.files['file']
if not uploaded_file:
abort(404)
uploaded_file.save(os.path.join(OVERRIDE_CONFIG_DIRECTORY, filename))
return {
'status': True
}
abort(403)
@resource('/v1/superuser/config/createsuperuser')
@internal_only
@show_if(features.SUPER_USERS)
@hide_if(features.BILLING) # Make sure it is never allowed in prod.
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')
@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 os.path.exists(OVERRIDE_CONFIG_YAML_FILENAME) 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.
set_config_value(OVERRIDE_CONFIG_YAML_FILENAME, 'SUPER_USERS', [username])
app.config['SUPER_USERS'] = [username]
# Conduct login with that user.
common_login(superuser)
return {
'status': True
}
abort(403)
@resource('/v1/superuser/config/validate/<service>')
@internal_only
@show_if(features.SUPER_USERS)
@hide_if(features.BILLING) # Make sure it is never allowed in prod.
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')
@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 os.path.exists(OVERRIDE_CONFIG_YAML_FILENAME) or SuperUserPermission().can():
config = request.get_json()['config']
if service == 'database':
try:
validate_database_url(config['DB_URI'])
return {
'status': True
}
except Exception as ex:
logger.exception('Could not validate database')
return {
'status': False,
'reason': str(ex)
}
return {}
abort(403)

View file

@ -24,9 +24,9 @@ EXTERNAL_CSS = [
]
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',
]

View file

@ -4896,3 +4896,25 @@ i.slack-icon {
.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;
}
.initial-setup-modal .valid-database .verified {
font-size: 16px;
margin-bottom: 16px;
}
.initial-setup-modal .valid-database .verified i.fa {
font-size: 26px;
margin-right: 10px;
vertical-align: middle;
color: rgb(53, 186, 53);
}

View file

@ -6,19 +6,6 @@
</div>
<div class="co-panel-body">
<table class="config-table">
<tr>
<td>Secret Key:</td>
<td>
<span class="config-string-field" binding="config.SECRET_KEY"
placeholder="A unique secret key"></span>
<div class="help-text">
This should be a UUID or some other secret string
</div>
</td>
<td>
<button class="btn btn-primary" ng-click="generateKey()">Generate Key</button>
</td>
</tr>
<tr>
<td>Enterprise Logo URL:</td>
<td>
@ -140,73 +127,6 @@
</div>
</div>
<!-- Database -->
<div class="co-panel">
<div class="co-panel-heading">
<i class="fa fa-database"></i> Database
</div>
<div class="co-panel-body">
<!--<a href="https://coreos.com/docs/enterprise-registry/mysql-container/" target="_blank">
Use a prebuilt Docker container
</a>-->
<div class="description">
<p>A MySQL RDBMS or Postgres installation with an empty database is required. The schema will be created the first time the registry image is run with valid configuration.</p>
</div>
<div class="config-parsed-field" binding="config.DB_URI"
parser="parseDbUri(value)"
serializer="serializeDbUri(fields)">
<table class="config-table">
<tr>
<td class="non-input">Database Type:</td>
<td>
<select ng-model="kind">
<option value="mysql+pymysql">MySQL</option>
<option value="postgresql">Postgres</option>
</select>
</td>
</tr>
<tr>
<td>Database Server:</td>
<td>
<span class="config-string-field" binding="server"
placeholder="The database server hostname"></span>
</td>
</tr>
<tr>
<td>Database Name:</td>
<td>
<span class="config-string-field" binding="database"
placeholder="The name of the database on the server"></span>
</td>
</tr>
<tr>
<td>Username:</td>
<td>
<span class="config-string-field" binding="username"
placeholder="Username for accessing the database"></span>
<div class="help-text">The user must have full access to the database</div>
</td>
</tr>
<tr>
<td>Password:</td>
<td>
<span class="config-string-field" binding="password"
placeholder="Password for accessing the database"></span>
</td>
</tr>
</table>
<div class="co-panel-button-bar">
<button class="btn btn-default"><i class="fa fa-sign-in"></i> Test Configuration</button>
</div>
</div>
</div>
</div> <!-- /Database -->
<!-- Redis -->
<div class="co-panel">
<div class="co-panel-heading">
@ -448,11 +368,11 @@
<td>Authentication:</td>
<td>
<div class="co-checkbox">
<input id="uma" type="checkbox" ng-model="mapped.use_mail_auth">
<input id="uma" type="checkbox" ng-model="config.MAIL_USE_AUTH">
<label for="uma">Requires Authentication</label>
</div>
<table class="config-table" ng-show="mapped.use_mail_auth">
<table class="config-table" ng-show="config.MAIL_USE_AUTH">
<tr>
<td>Username:</td>
<td>

View file

@ -2818,6 +2818,7 @@ function SuperUserAdminCtrl($scope, $timeout, ApiService, Features, UserService,
// Monitor any user changes and place the current user into the scope.
UserService.updateUserIn($scope);
$scope.configStatus = null;
$scope.logsCounter = 0;
$scope.newUser = {};
$scope.createdUser = null;
@ -2993,6 +2994,154 @@ function SuperUserAdminCtrl($scope, $timeout, ApiService, Features, UserService,
}, ApiService.errorDisplay('Cannot send recovery email'))
};
$scope.parseDbUri = function(value) {
if (!value) { return null; }
// Format: mysql+pymysql://<username>:<url escaped password>@<hostname>/<database_name>
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 {
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.configStep = 'creating-superuser';
ApiService.scCreateInitialSuperuser($scope.superUser, null).then(function(resp) {
UserService.load();
$('#createSuperuserModal').modal('hide');
$scope.checkContainerStatus();
}, ApiService.errorDisplay('Could not create superuser'));
};
$scope.checkContainerStatus = function() {
var errorHandler = function(resp) {
if (resp.status == 404 && $scope.configStep == 'valid-database') {
// Container has not yet come back up, so we schedule another check.
$scope.waitForValidConfig();
return;
}
return ApiService.errorDisplay('Cannot load status. Please report this to support')(resp);
};
ApiService.scRegistryStatus(null, null).then(function(resp) {
$scope.configStatus = resp;
// !dir_exists -> No mounted directory.
if (!$scope.configStatus.dir_exists) {
bootbox.dialog({
"message": "No volume was found mounted at path <code>/conf/stack</code>. " +
"Please rerun the container with the volume mounted and refresh this page." +
"<br><br>For more information: " +
"<a href='https://coreos.com/docs/enterprise-registry/initial-setup/'>" +
"Enterprise Registry Setup Guide</a>",
"title": "Missing mounted configuration volume",
"buttons": {},
"closeButton": false
});
return;
}
// is_testing = False -> valid config
// ready = False -> no valid superusers yet
if (!$scope.configStatus.is_testing && !$scope.configStatus.ready) {
$('#initializeConfigModal').modal('hide');
$scope.superUser = {};
$scope.configStep = 'create-superuser';
$('#createSuperuserModal').modal({
keyboard: false,
backdrop: 'static'
});
return;
}
// file_exists -> config file, but possibly invalid DB
// valid_db = False -> invalid DB
// is_testing = True -> still in testing mode
if (!$scope.configStatus.file_exists || !$scope.configStatus.valid_db ||
$scope.configStatus.is_testing) {
$('#createSuperuserModal').modal('hide');
$scope.databaseUri = '';
$scope.configStep = 'enter-database';
// Handle the case where they have entered a valid DB config, refreshed, but have not
// yet restarted the DB container.
if ($scope.configStatus.file_exists && $scope.configStatus.is_testing) {
$scope.waitForValidConfig();
}
$('#initializeConfigModal').modal({
keyboard: false,
backdrop: 'static'
});
return;
}
}, errorHandler, /* background */true);
};
$scope.waitForValidConfig = function() {
$scope.configStep = 'valid-database';
$timeout(function() {
$scope.checkContainerStatus();
}, 3000);
};
$scope.validateDatabase = function() {
$scope.configStep = 'validating-database';
$scope.databaseInvalid = null;
var data = {
'config': {
'DB_URI': $scope.databaseUri
}
};
var params = {
'service': 'database'
};
ApiService.scValidateConfig(data, params).then(function(resp) {
var status = resp.status;
if (status) {
$scope.configStep = 'updating-config';
ApiService.scUpdateConfig(data, null).then(function(resp) {
$scope.waitForValidConfig();
}, ApiService.errorDisplay('Cannot update config. Please report this to support'));
} else {
$scope.configStep = 'invalid-database';
$scope.databaseInvalid = resp.reason;
}
}, ApiService.errorDisplay('Cannot validate database. Please report this to support'));
};
// Load the configuration status.
$scope.checkContainerStatus();
}
function TourCtrl($scope, $location) {

View file

@ -78,10 +78,11 @@ angular.module("core-config-setup", ['angularFileUpload'])
$transclude(function(clone, scope) {
$scope.childScope = scope;
$scope.childScope['fields'] = {};
$element.append(clone);
});
$scope.childScope.$watch(function(value) {
$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
@ -89,13 +90,13 @@ angular.module("core-config-setup", ['angularFileUpload'])
$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[key] = parsed[key];
$scope.childScope['fields'][key] = parsed[key];
}
}
});
@ -240,7 +241,7 @@ angular.module("core-config-setup", ['angularFileUpload'])
$scope.uploadProgress = 0;
$scope.upload = $upload.upload({
url: '/api/v1/configfile',
url: '/api/v1/superuser/config/file',
method: 'POST',
data: { filename: $scope.filename },
file: files[0],
@ -257,7 +258,7 @@ angular.module("core-config-setup", ['angularFileUpload'])
};
var loadStatus = function(filename) {
Restangular.one('configfile/' + filename).get().then(function(resp) {
Restangular.one('superuser/config/file/' + filename).get().then(function(resp) {
$scope.hasFile = resp['exists'];
});
};

View file

@ -1,265 +1,433 @@
<div class="page-content" quay-show="Features.SUPER_USERS">
<div class="cor-title">
<span class="cor-title-link"></span>
<span class="cor-title-content">Enterprise Registry Management</span>
</div>
<div>
<div class="quay-spinner" ng-show="!configStatus"></div>
<div class="page-content" quay-show="Features.SUPER_USERS && configStatus.ready">
<div class="cor-title">
<span class="cor-title-link"></span>
<span class="cor-title-content">Enterprise Registry Management</span>
</div>
<div class="cor-tab-panel">
<div class="cor-tabs">
<span class="cor-tab" tab-active="true" tab-title="Registry Settings" tab-target="#setup"
tab-init="loadConfig()">
<i class="fa fa-cog"></i>
</span>
<span class="cor-tab" tab-title="Manage Users" tab-target="#users" tab-init="loadUsers()">
<i class="fa fa-group"></i>
</span>
<span class="cor-tab" tab-title="Container Usage" tab-target="#usage-counter" tab-init="getUsage()">
<i class="fa fa-pie-chart"></i>
</span>
<span class="cor-tab" tab-title="Usage Logs" tab-target="#logs" tab-init="loadUsageLogs()">
<i class="fa fa-bar-chart"></i>
</span>
<span class="cor-tab" tab-title="Internal Logs and Debugging" tab-target="#debug" tab-init="loadDebugServices()">
<i class="fa fa-bug"></i>
</span>
</div> <!-- /cor-tabs -->
<div class="cor-tab-panel">
<div class="cor-tabs">
<span class="cor-tab" tab-active="true" tab-title="Registry Settings" tab-target="#setup"
tab-init="loadConfig()">
<i class="fa fa-cog"></i>
</span>
<span class="cor-tab" tab-title="Manage Users" tab-target="#users" tab-init="loadUsers()">
<i class="fa fa-group"></i>
</span>
<span class="cor-tab" tab-title="Container Usage" tab-target="#usage-counter" tab-init="getUsage()">
<i class="fa fa-pie-chart"></i>
</span>
<span class="cor-tab" tab-title="Usage Logs" tab-target="#logs" tab-init="loadUsageLogs()">
<i class="fa fa-bar-chart"></i>
</span>
<span class="cor-tab" tab-title="Internal Logs and Debugging" tab-target="#debug" tab-init="loadDebugServices()">
<i class="fa fa-bug"></i>
</span>
</div> <!-- /cor-tabs -->
<div class="cor-tab-content">
<!-- Setup tab -->
<div id="setup" class="tab-pane active">
<div class="config-setup-tool"></div>
</div>
<!-- Debugging tab -->
<div id="debug" class="tab-pane">
<div class="quay-spinner" ng-show="!debugServices"></div>
<div role="tabpanel" ng-show="debugServices">
<!-- Nav tabs -->
<ul class="nav nav-tabs" role="tablist">
<li role="presentation" ng-repeat="service in debugServices"
ng-class="debugService == service ? 'active' : ''">
<a href="javascript:void(0)" ng-click="viewSystemLogs(service)">{{ service }}</a>
</li>
</ul>
<div class="system-log-download-panel" ng-if="!debugService">
Select a service above to view its local logs
<div>
<a class="btn btn-primary" href="/systemlogsarchive?_csrf_token={{ csrf_token }}" target="_blank">
<i class="fa fa-download fa-lg" style="margin-right: 4px;"></i> Download All Local Logs (.tar.gz)
</a>
</div>
</div>
<div class="cor-log-box" logs="debugLogs" ng-show="debugService"></div>
</div>
</div>
<!-- Logs tab -->
<div id="logs" class="tab-pane">
<div class="logsView" makevisible="logsCounter" all-logs="true"></div>
</div> <!-- /logs tab-->
<!-- Usage tab -->
<div id="usage-counter" class="tab-pane">
<div class="quay-spinner" ng-show="systemUsage == null"></div>
<div class="usage-chart" total="systemUsage.allowed" limit="systemUsageLimit"
current="systemUsage.usage" usage-title="Deployed Containers"></div>
<!-- Alerts -->
<div class="alert alert-danger" ng-show="systemUsageLimit == 'over' && systemUsage">
You have deployed more repositories than your plan allows. Please
upgrade your subscription by contacting <a href="mailto:sales@coreos.com">CoreOS Sales</a>.
<div class="cor-tab-content">
<!-- Setup tab -->
<div id="setup" class="tab-pane active">
<div class="config-setup-tool"></div>
</div>
<div class="alert alert-warning" ng-show="systemUsageLimit == 'at' && systemUsage">
You are at your current plan's number of allowed repositories. It might be time to think about
upgrading your subscription by contacting <a href="mailto:sales@coreos.com">CoreOS Sales</a>.
</div>
<!-- Debugging tab -->
<div id="debug" class="tab-pane">
<div class="quay-spinner" ng-show="!debugServices"></div>
<div class="alert alert-success" ng-show="systemUsageLimit == 'near' && systemUsage">
You are nearing the number of allowed deployed repositories. It might be time to think about
upgrading your subscription by contacting <a href="mailto:sales@coreos.com">CoreOS Sales</a>.
</div>
<div role="tabpanel" ng-show="debugServices">
<!-- Nav tabs -->
<ul class="nav nav-tabs" role="tablist">
<li role="presentation" ng-repeat="service in debugServices"
ng-class="debugService == service ? 'active' : ''">
<a href="javascript:void(0)" ng-click="viewSystemLogs(service)">{{ service }}</a>
</li>
</ul>
For more information: <a href="https://coreos.com/products/enterprise-registry/plans/">See Here</a>.
</div> <!-- /usage-counter tab-->
<div class="system-log-download-panel" ng-if="!debugService">
Select a service above to view its local logs
<!-- Users tab -->
<div id="users" class="tab-pane">
<div class="quay-spinner" ng-show="!users"></div>
<div class="alert alert-error" ng-show="usersError">
{{ usersError }}
</div>
<div ng-show="users">
<div class="side-controls">
<div class="result-count">
Showing {{(users | filter:search | limitTo:100).length}} of
{{(users | filter:search).length}} matching users
<div>
<a class="btn btn-primary" href="/systemlogsarchive?_csrf_token={{ csrf_token }}" target="_blank">
<i class="fa fa-download fa-lg" style="margin-right: 4px;"></i> Download All Local Logs (.tar.gz)
</a>
</div>
<div class="filter-input">
<input id="log-filter" class="form-control" placeholder="Filter Users" type="text" ng-model="search.$">
</div>
<div class="cor-log-box" logs="debugLogs" ng-show="debugService"></div>
</div>
</div>
<!-- Logs tab -->
<div id="logs" class="tab-pane">
<div class="logsView" makevisible="logsCounter" all-logs="true"></div>
</div> <!-- /logs tab-->
<!-- Usage tab -->
<div id="usage-counter" class="tab-pane">
<div class="quay-spinner" ng-show="systemUsage == null"></div>
<div class="usage-chart" total="systemUsage.allowed" limit="systemUsageLimit"
current="systemUsage.usage" usage-title="Deployed Containers"></div>
<!-- Alerts -->
<div class="alert alert-danger" ng-show="systemUsageLimit == 'over' && systemUsage">
You have deployed more repositories than your plan allows. Please
upgrade your subscription by contacting <a href="mailto:sales@coreos.com">CoreOS Sales</a>.
</div>
<div class="alert alert-warning" ng-show="systemUsageLimit == 'at' && systemUsage">
You are at your current plan's number of allowed repositories. It might be time to think about
upgrading your subscription by contacting <a href="mailto:sales@coreos.com">CoreOS Sales</a>.
</div>
<div class="alert alert-success" ng-show="systemUsageLimit == 'near' && systemUsage">
You are nearing the number of allowed deployed repositories. It might be time to think about
upgrading your subscription by contacting <a href="mailto:sales@coreos.com">CoreOS Sales</a>.
</div>
For more information: <a href="https://coreos.com/products/enterprise-registry/plans/">See Here</a>.
</div> <!-- /usage-counter tab-->
<!-- Users tab -->
<div id="users" class="tab-pane">
<div class="quay-spinner" ng-show="!users"></div>
<div class="alert alert-error" ng-show="usersError">
{{ usersError }}
</div>
<div ng-show="users">
<div class="side-controls">
<div class="result-count">
Showing {{(users | filter:search | limitTo:100).length}} of
{{(users | filter:search).length}} matching users
</div>
<div class="filter-input">
<input id="log-filter" class="form-control" placeholder="Filter Users" type="text" ng-model="search.$">
</div>
<button class="btn btn-primary" style="vertical-align: top; margin-left: 10px;"
ng-click="showCreateUser()">
<i class="fa fa-plus" style="margin-right: 6px;"></i>Create User
</button>
</div>
<button class="btn btn-primary" style="vertical-align: top; margin-left: 10px;"
ng-click="showCreateUser()">
<i class="fa fa-plus" style="margin-right: 6px;"></i>Create User
</button>
</div>
<table class="table">
<thead>
<th style="width: 24px;"></th>
<th>Username</th>
<th>E-mail address</th>
<th style="width: 24px;"></th>
</thead>
<tr ng-repeat="current_user in (users | filter:search | orderBy:'username' | limitTo:100)"
class="user-row">
<td>
<span class="avatar" hash="current_user.avatar" size="24"></span>
</td>
<td>
<span class="labels">
<span class="label label-default" ng-if="user.username == current_user.username">
You
</span>
<span class="label label-primary"
ng-if="current_user.super_user">
Superuser
</span>
</span>
{{ current_user.username }}
</td>
<td>
<a href="mailto:{{ current_user.email }}">{{ current_user.email }}</a>
</td>
<td style="text-align: center;">
<span class="cor-options-menu"
ng-if="user.username != current_user.username && !current_user.super_user">
<span class="cor-option" option-click="showChangePassword(current_user)">
<i class="fa fa-key"></i> Change Password
</span>
<span class="cor-option" option-click="sendRecoveryEmail(current_user)"
quay-show="Features.MAILING">
<i class="fa fa-envelope"></i> Send Recovery Email
</span>
<span class="cor-option" option-click="showDeleteUser(current_user)">
<i class="fa fa-times"></i> Delete User
</span>
</span>
</td>
</tr>
</table>
</div> <!-- /show if users -->
</div> <!-- users-tab -->
</div> <!-- /cor-tab-content -->
</div> <!-- /cor-tab-panel -->
<!-- Modal message dialog -->
<div class="modal fade" id="confirmDeleteUserModal">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-hidden="true">&times;</button>
<h4 class="modal-title">Delete User?</h4>
</div>
<div class="modal-body">
<div class="alert alert-danger">
This operation <strong>cannot be undone</strong> and will <strong>delete any repositories owned by the user</strong>.
</div>
Are you <strong>sure</strong> you want to delete user <strong>{{ userToDelete.username }}</strong>?
</div>
<div class="modal-footer">
<button type="button" class="btn btn-danger" ng-click="deleteUser(userToDelete)">Delete User</button>
<button type="button" class="btn btn-default" data-dismiss="modal">Cancel</button>
</div>
</div><!-- /.modal-content -->
</div><!-- /.modal-dialog -->
</div><!-- /.modal -->
<!-- Modal message dialog -->
<div class="modal fade" id="createUserModal">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-hidden="true">&times;</button>
<h4 class="modal-title">Create New User</h4>
</div>
<form name="createUserForm" ng-submit="createUser()">
<div class="modal-body" ng-show="createdUser">
<table class="table">
<thead>
<th style="width: 24px;"></th>
<th>Username</th>
<th>E-mail address</th>
<th>Temporary Password</th>
<th style="width: 24px;"></th>
</thead>
<tr class="user-row">
<td>{{ createdUser.username }}</td>
<td>{{ createdUser.email }}</td>
<td>{{ createdUser.password }}</td>
<tr ng-repeat="current_user in (users | filter:search | orderBy:'username' | limitTo:100)"
class="user-row">
<td>
<span class="avatar" hash="current_user.avatar" size="24"></span>
</td>
<td>
<span class="labels">
<span class="label label-default" ng-if="user.username == current_user.username">
You
</span>
<span class="label label-primary"
ng-if="current_user.super_user">
Superuser
</span>
</span>
{{ current_user.username }}
</td>
<td>
<a href="mailto:{{ current_user.email }}">{{ current_user.email }}</a>
</td>
<td style="text-align: center;">
<span class="cor-options-menu"
ng-if="user.username != current_user.username && !current_user.super_user">
<span class="cor-option" option-click="showChangePassword(current_user)">
<i class="fa fa-key"></i> Change Password
</span>
<span class="cor-option" option-click="sendRecoveryEmail(current_user)"
quay-show="Features.MAILING">
<i class="fa fa-envelope"></i> Send Recovery Email
</span>
<span class="cor-option" option-click="showDeleteUser(current_user)">
<i class="fa fa-times"></i> Delete User
</span>
</span>
</td>
</tr>
</table>
</div>
<div class="modal-body" ng-show="creatingUser">
<div class="quay-spinner"></div>
</div>
<div class="modal-body" ng-show="!creatingUser && !createdUser">
<div class="form-group">
<label>Username</label>
<input class="form-control" type="text" ng-model="newUser.username" ng-pattern="/^[a-z0-9_]{4,30}$/" required>
</div>
</div> <!-- /show if users -->
</div> <!-- users-tab -->
</div> <!-- /cor-tab-content -->
</div> <!-- /cor-tab-panel -->
<div class="form-group">
<label>Email address</label>
<input class="form-control" type="email" ng-model="newUser.email" required>
</div>
<!-- Modal message dialog -->
<div class="modal fade" id="confirmDeleteUserModal">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-hidden="true">&times;</button>
<h4 class="modal-title">Delete User?</h4>
</div>
<div class="modal-footer" ng-show="createdUser">
<button type="button" class="btn btn-default" data-dismiss="modal">Close</button>
<div class="modal-body">
<div class="alert alert-danger">
This operation <strong>cannot be undone</strong> and will <strong>delete any repositories owned by the user</strong>.
</div>
Are you <strong>sure</strong> you want to delete user <strong>{{ userToDelete.username }}</strong>?
</div>
<div class="modal-footer" ng-show="!creatingUser && !createdUser">
<button class="btn btn-primary" type="submit" ng-disabled="!createUserForm.$valid">
Create User
</button>
<div class="modal-footer">
<button type="button" class="btn btn-danger" ng-click="deleteUser(userToDelete)">Delete User</button>
<button type="button" class="btn btn-default" data-dismiss="modal">Cancel</button>
</div>
</div><!-- /.modal-content -->
</div><!-- /.modal-dialog -->
</div><!-- /.modal -->
<!-- Modal message dialog -->
<div class="modal fade" id="createUserModal">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-hidden="true">&times;</button>
<h4 class="modal-title">Create New User</h4>
</div>
<form name="createUserForm" ng-submit="createUser()">
<div class="modal-body" ng-show="createdUser">
<table class="table">
<thead>
<th>Username</th>
<th>E-mail address</th>
<th>Temporary Password</th>
</thead>
<tr class="user-row">
<td>{{ createdUser.username }}</td>
<td>{{ createdUser.email }}</td>
<td>{{ createdUser.password }}</td>
</tr>
</table>
</div>
<div class="modal-body" ng-show="creatingUser">
<div class="quay-spinner"></div>
</div>
<div class="modal-body" ng-show="!creatingUser && !createdUser">
<div class="form-group">
<label>Username</label>
<input class="form-control" type="text" ng-model="newUser.username" ng-pattern="/^[a-z0-9_]{4,30}$/" required>
</div>
<div class="form-group">
<label>Email address</label>
<input class="form-control" type="email" ng-model="newUser.email" required>
</div>
</div>
<div class="modal-footer" ng-show="createdUser">
<button type="button" class="btn btn-default" data-dismiss="modal">Close</button>
</div>
<div class="modal-footer" ng-show="!creatingUser && !createdUser">
<button class="btn btn-primary" type="submit" ng-disabled="!createUserForm.$valid">
Create User
</button>
<button type="button" class="btn btn-default" data-dismiss="modal">Cancel</button>
</div>
</form>
</div><!-- /.modal-content -->
</div><!-- /.modal-dialog -->
</div><!-- /.modal -->
<!-- Modal message dialog -->
<div class="modal fade" id="changePasswordModal">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-hidden="true">&times;</button>
<h4 class="modal-title">Change User Password</h4>
</div>
<div class="modal-body">
<div class="alert alert-warning">
The user will no longer be able to access the registry with their current password
</div>
<form class="form-change" id="changePasswordForm" name="changePasswordForm" data-trigger="manual">
<input type="password" class="form-control" placeholder="User's new password" ng-model="userToChange.password" required ng-pattern="/^.{8,}$/">
<input type="password" class="form-control" placeholder="Verify the new password" ng-model="userToChange.repeatPassword"
match="userToChange.password" required ng-pattern="/^.{8,}$/">
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-primary" ng-click="changeUserPassword(userToChange)"
ng-disabled="changePasswordForm.$invalid">Change User Password</button>
<button type="button" class="btn btn-default" data-dismiss="modal">Cancel</button>
</div>
</div><!-- /.modal-content -->
</div><!-- /.modal-dialog -->
</div><!-- /.modal -->
</div> <!-- /page-content -->
<!-- Modal message dialog -->
<div class="modal fade initial-setup-modal" id="createSuperuserModal">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title"><span><span class="registry-name"></span> Setup</h4></span>
</div>
<div class="modal-body config-setup-tool-element" ng-show="configStep == 'creating-superuser'">
Creating super user account.... Please Wait
</div>
<form id="superuserForm" name="superuserForm" ng-submit="createSuperUser()">
<div class="modal-body config-setup-tool-element" ng-show="configStep == 'create-superuser'">
<p>A super user account is required to manage the <span class="registry-name"></span>
installation. Please enter details for the new account below.</p>
<div class="form-group">
<label>Username</label>
<input class="form-control" type="text" ng-model="superUser.username"
ng-pattern="/^[a-z0-9_]{4,30}$/" required>
</div>
<div class="form-group">
<label>Email address</label>
<input class="form-control" type="email" ng-model="superUser.email" required>
</div>
<div class="form-group">
<label>Password</label>
<input class="form-control" type="password" ng-model="superUser.password" required>
</div>
<div class="form-group">
<label>Repeat Password</label>
<input class="form-control" type="password" ng-model="superUser.repeatPassword"
match="superUser.password" required>
</div>
</div>
<div class="modal-footer">
<button type="submit" class="btn btn-primary" ng-disabled="!superuserForm.$valid"
ng-show="configStep == 'create-superuser'">
Create Super User
</button>
<span class="modal-body config-setup-tool-element" ng-show="configStep == 'creating-superuser'">
<span class="quay-spinner"></span>
Creating account...
</span>
</div>
</form>
</div><!-- /.modal-content -->
</div><!-- /.modal-dialog -->
</div><!-- /.modal -->
<!-- Modal message dialog -->
<div class="modal fade" id="changePasswordModal">
<div class="modal fade initial-setup-modal" id="initializeConfigModal">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-hidden="true">&times;</button>
<h4 class="modal-title">Change User Password</h4>
<h4 class="modal-title"><span><span class="registry-name"></span> Setup</h4></span>
</div>
<div class="modal-body">
<div class="alert alert-warning">
The user will no longer be able to access the registry with their current password
<div class="modal-body config-setup-tool-element valid-database" ng-show="configStep == 'valid-database'">
<div class="verified">
<i class="fa fa-check-circle"></i> Your database has been verified as working.
</div>
<form class="form-change" id="changePasswordForm" name="changePasswordForm" data-trigger="manual">
<input type="password" class="form-control" placeholder="User's new password" ng-model="userToChange.password" required ng-pattern="/^.{8,}$/">
<input type="password" class="form-control" placeholder="Verify the new password" ng-model="userToChange.repeatPassword"
match="userToChange.password" required ng-pattern="/^.{8,}$/">
</form>
<p>
<strong>Please restart the <span class="registry-name"></span> container</strong>, which will automatically generate the database's schema.
</p>
<p>This operation may take a few minutes.</p>
</div>
<div class="modal-body config-setup-tool-element" ng-show="configStep == 'updating-config'">
Updating Configuration.... Please Wait
</div>
<div class="modal-body config-setup-tool-element" ng-show="configStep == 'validating-database'">
Validating Database.... Please Wait
</div>
<div class="modal-body config-setup-tool-element"
ng-show="configStep == 'enter-database' || configStep == 'invalid-database'">
<div class="alert alert-warning" ng-show="configStatus.has_file">
Could not connect to or validate the database configuration found. Please reconfigure to continue.
</div>
<div class="alert alert-warning" ng-show="databaseInvalid">
Database Validation Issue: {{ databaseInvalid }}
</div>
<p>
Please enter the connection details for your <strong>empty</strong> database. The schema will be created in the following step.</p>
</p>
<div class="config-parsed-field" binding="databaseUri"
parser="parseDbUri(value)"
serializer="serializeDbUri(fields)">
<table class="config-table">
<tr>
<td class="non-input">Database Type:</td>
<td>
<select ng-model="fields.kind">
<option value="mysql+pymysql">MySQL</option>
<option value="postgresql">Postgres</option>
</select>
</td>
</tr>
</table>
<table class="config-table" ng-show="fields.kind">
<tr>
<td>Database Server:</td>
<td>
<span class="config-string-field" binding="fields.server"
placeholder="The database server hostname"></span>
</td>
</tr>
<tr>
<td>Database Name:</td>
<td>
<span class="config-string-field" binding="fields.database"
placeholder="The name of the database on the server"></span>
</td>
</tr>
<tr>
<td>Username:</td>
<td>
<span class="config-string-field" binding="fields.username"
placeholder="Username for accessing the database"></span>
<div class="help-text">The user must have full access to the database</div>
</td>
</tr>
<tr>
<td>Password:</td>
<td>
<input class="form-control" type="password" ng-model="fields.password"></span>
</td>
</tr>
</table>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-primary" ng-click="changeUserPassword(userToChange)"
ng-disabled="changePasswordForm.$invalid">Change User Password</button>
<button type="button" class="btn btn-default" data-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-primary" ng-disabled="!databaseUri"
ng-click="validateDatabase()"
ng-show="configStep == 'enter-database' || configStep == 'invalid-database'">
Confirm Database
</button>
<span class="modal-body config-setup-tool-element" ng-show="configStep == 'validating-database'">
<span class="quay-spinner"></span>
Validating Database...
</span>
<span class="modal-body config-setup-tool-element" ng-show="configStep == 'updating-config'">
<span class="quay-spinner"></span>
Updating Configuration...
</span>
<span class="modal-body config-setup-tool-element" ng-show="configStep == 'valid-database'">
<span class="quay-spinner"></span>
Waiting For Updated Container...
</span>
</div>
</div><!-- /.modal-content -->
</div><!-- /.modal-dialog -->
</div><!-- /.modal -->
</div> <!-- /page-content -->
</div>

Binary file not shown.

70
util/configutil.py Normal file
View file

@ -0,0 +1,70 @@
import yaml
from random import SystemRandom
def generate_secret_key():
cryptogen = SystemRandom()
return str(cryptogen.getrandbits(256))
def import_yaml(config_obj, config_file):
with open(config_file) as f:
c = yaml.safe_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():
config_obj[key] = c[key]
def export_yaml(config_obj, config_file):
with open(config_file, 'w') as f:
f.write(yaml.safe_dump(config_obj, encoding='utf-8', allow_unicode=True))
def set_config_value(config_file, config_key, value):
""" Loads the configuration from the given YAML config file, sets the given key to
the given value, and then writes it back out to the given YAML config file. """
config_obj = {}
import_yaml(config_obj, config_file)
config_obj[config_key] = value
export_yaml(config_obj, config_file)
def add_enterprise_config_defaults(config_obj):
""" Adds/Sets the config defaults for enterprise registry config. """
# These have to be false.
config_obj['TESTING'] = False
config_obj['USE_CDN'] = False
# Default features that are on.
config_obj['FEATURE_USER_LOG_ACCESS'] = config_obj.get('FEATURE_USER_LOG_ACCESS', True)
config_obj['FEATURE_USER_CREATION'] = config_obj.get('FEATURE_USER_CREATION', True)
# Default features that are off.
config_obj['FEATURE_MAILING'] = config_obj.get('FEATURE_MAILING', False)
config_obj['FEATURE_BUILD_SUPPORT'] = config_obj.get('FEATURE_BUILD_SUPPORT', False)
# Default secret key.
if not 'SECRET_KEY' in config_obj:
config_obj['SECRET_KEY'] = generate_secret_key()
# Default storage configuration.
if not 'DISTRIBUTED_STORAGE_CONFIG' in config_obj:
config_obj['DISTRIBUTED_STORAGE_PREFERENCE'] = ['local']
config_obj['DISTRIBUTED_STORAGE_CONFIG'] = {
'local': ['LocalStorage', { 'storage_path': '/datastorage/registry' }]
}
config_obj['USERFILES_LOCATION'] = 'local'
config_obj['USERFILES_PATH'] = 'userfiles/'
# Misc configuration.
config_obj['PREFERRED_URL_SCHEME'] = config_obj.get('PREFERRED_URL_SCHEME', 'http')
config_obj['ENTERPRISE_LOGO_URL'] = config_obj.get('ENTERPRISE_LOGO_URL',
'/static/img/quay-logo.png')