Get end-to-end configuration setup working, including verification (except for Github, which is in progress)
This commit is contained in:
parent
825455ea6c
commit
63504c87fb
14 changed files with 611 additions and 206 deletions
8
app.py
8
app.py
|
@ -19,7 +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, generate_secret_key
|
||||
from util.config.configutil import import_yaml, generate_secret_key
|
||||
from data.billing import Billing
|
||||
from data.buildlogs import BuildLogs
|
||||
from data.archivedlogs import LogArchive
|
||||
|
@ -124,9 +124,9 @@ queue_metrics = QueueMetrics(app)
|
|||
authentication = UserAuthentication(app)
|
||||
userevents = UserEventsBuilderModule(app)
|
||||
|
||||
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]
|
||||
|
||||
tf = app.config['DB_TRANSACTION_FACTORY']
|
||||
|
|
|
@ -9,18 +9,17 @@ from endpoints.api import (ApiResource, nickname, resource, internal_only, show_
|
|||
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)
|
||||
from data.database import User
|
||||
from util.config.configutil import (import_yaml, export_yaml, add_enterprise_config_defaults,
|
||||
set_config_value)
|
||||
from util.config.validator import validate_service_for_config, SSL_FILENAMES
|
||||
|
||||
import features
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
CONFIG_FILE_WHITELIST = ['ssl.key', 'ssl.cert']
|
||||
|
||||
def database_is_valid():
|
||||
try:
|
||||
User.select().limit(1)
|
||||
|
@ -131,7 +130,7 @@ class SuperUserConfigFile(ApiResource):
|
|||
@nickname('scConfigFileExists')
|
||||
def get(self, filename):
|
||||
""" Returns whether the configuration file with the given name exists. """
|
||||
if not filename in CONFIG_FILE_WHITELIST:
|
||||
if not filename in SSL_FILENAMES:
|
||||
abort(404)
|
||||
|
||||
if SuperUserPermission().can():
|
||||
|
@ -260,19 +259,6 @@ class SuperUserConfigValidate(ApiResource):
|
|||
# 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 {}
|
||||
return validate_service_for_config(service, config)
|
||||
|
||||
abort(403)
|
|
@ -231,6 +231,31 @@
|
|||
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;
|
||||
|
@ -338,4 +363,64 @@
|
|||
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 {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
|
|
@ -4907,12 +4907,12 @@ i.slack-icon {
|
|||
font-size: 18px;
|
||||
}
|
||||
|
||||
.initial-setup-modal .valid-database .verified {
|
||||
.verified {
|
||||
font-size: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.initial-setup-modal .valid-database .verified i.fa {
|
||||
.verified i.fa {
|
||||
font-size: 26px;
|
||||
margin-right: 10px;
|
||||
vertical-align: middle;
|
||||
|
@ -4923,8 +4923,4 @@ i.slack-icon {
|
|||
border: 1px solid #eee;
|
||||
vertical-align: middle;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.config-contact-field .form-control {
|
||||
width: 350px;
|
||||
}
|
|
@ -5,10 +5,10 @@
|
|||
<div class="dropdown">
|
||||
<button class="btn btn-default dropdown-toggle" type="button" data-toggle="dropdown">
|
||||
<span ng-switch="kind">
|
||||
<span ng-switch-when="mailto"><i class="fa fa-envelope"></i></span>
|
||||
<span ng-switch-when="irc"><i class="fa fa-comment"></i></span>
|
||||
<span ng-switch-when="tel"><i class="fa fa-phone"></i></span>
|
||||
<span ng-switch-default><i class="fa fa-ticket"></i></span>
|
||||
<span ng-switch-when="mailto"><i class="fa fa-envelope"></i>E-mail</span>
|
||||
<span ng-switch-when="irc"><i class="fa fa-comment"></i>IRC</span>
|
||||
<span ng-switch-when="tel"><i class="fa fa-phone"></i>Phone</span>
|
||||
<span ng-switch-default><i class="fa fa-ticket"></i>URL</span>
|
||||
</span>
|
||||
<span class="caret"></span>
|
||||
</button>
|
||||
|
|
|
@ -44,23 +44,6 @@
|
|||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Build Support:</td>
|
||||
<td colspan="2">
|
||||
<div class="co-checkbox">
|
||||
<input id="ftbs" type="checkbox" ng-model="config.FEATURE_BUILD_SUPPORT">
|
||||
<label for="ftbs">Enable Dockerfile Build</label>
|
||||
</div>
|
||||
<div class="help-text">
|
||||
If enabled, users can submit Dockerfiles to be built and pushed by the Enterprise Registry.
|
||||
</div>
|
||||
|
||||
<div ng-if="config.FEATURE_BUILD_SUPPORT" style="margin-top: 10px">
|
||||
<strong>Note: Build workers are required for this feature.</strong>
|
||||
See <a href="https://coreos.com/docs/enterprise-registry/build-support/" target="_blank">Adding Build Workers</a> for instructions on how to setup build workers.
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -155,9 +138,6 @@
|
|||
</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> <!-- /Redis -->
|
||||
|
||||
|
@ -186,122 +166,27 @@
|
|||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Storage Path -->
|
||||
<tr>
|
||||
<td>Storage Path:</td>
|
||||
<!-- Fields -->
|
||||
<tr ng-repeat="field in STORAGE_CONFIG_FIELDS[config.DISTRIBUTED_STORAGE_CONFIG.local[0]]">
|
||||
<td>{{ field.title }}:</td>
|
||||
<td>
|
||||
<span class="config-string-field"
|
||||
binding="config.DISTRIBUTED_STORAGE_CONFIG.local[1].storage_path"
|
||||
placeholder="Path under the volume or bucket"></span>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- S3 -->
|
||||
<tr ng-show="config.DISTRIBUTED_STORAGE_CONFIG.local[0] == 'S3Storage'">
|
||||
<td>Access Key:</td>
|
||||
<td>
|
||||
<span class="config-string-field"
|
||||
binding="config.DISTRIBUTED_STORAGE_CONFIG.local[1].s3_access_key"
|
||||
placeholder="AWS access key"></span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr ng-show="config.DISTRIBUTED_STORAGE_CONFIG.local[0] == 'S3Storage'">
|
||||
<td>Secret Key:</td>
|
||||
<td>
|
||||
<span class="config-string-field"
|
||||
binding="config.DISTRIBUTED_STORAGE_CONFIG.local[1].s3_secret_key"
|
||||
placeholder="AWS secret key"></span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr ng-show="config.DISTRIBUTED_STORAGE_CONFIG.local[0] == 'S3Storage'">
|
||||
<td>Bucket Name:</td>
|
||||
<td>
|
||||
<span class="config-string-field"
|
||||
binding="config.DISTRIBUTED_STORAGE_CONFIG.local[1].s3_bucket"
|
||||
placeholder="S3 bucket name"></span>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- GCS -->
|
||||
<tr ng-show="config.DISTRIBUTED_STORAGE_CONFIG.local[0] == 'GoogleCloudStorage'">
|
||||
<td>Access Key:</td>
|
||||
<td>
|
||||
<span class="config-string-field"
|
||||
binding="config.DISTRIBUTED_STORAGE_CONFIG.local[1].access_key"
|
||||
placeholder="GCS access key"></span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr ng-show="config.DISTRIBUTED_STORAGE_CONFIG.local[0] == 'GoogleCloudStorage'">
|
||||
<td>Secret Key:</td>
|
||||
<td>
|
||||
<span class="config-string-field"
|
||||
binding="config.DISTRIBUTED_STORAGE_CONFIG.local[1].secret_key"
|
||||
placeholder="GCS secret key"></span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr ng-show="config.DISTRIBUTED_STORAGE_CONFIG.local[0] == 'GoogleCloudStorage'">
|
||||
<td>Bucket Name:</td>
|
||||
<td>
|
||||
<span class="config-string-field"
|
||||
binding="config.DISTRIBUTED_STORAGE_CONFIG.local[1].bucket_name"
|
||||
placeholder="GCS bucket name"></span>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- RADOS -->
|
||||
<tr ng-show="config.DISTRIBUTED_STORAGE_CONFIG.local[0] == 'RadosGWStorage'">
|
||||
<td>Hostname:</td>
|
||||
<td>
|
||||
<span class="config-string-field"
|
||||
binding="config.DISTRIBUTED_STORAGE_CONFIG.local[1].hostname"
|
||||
placeholder="RADOS Hostname"></span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr ng-show="config.DISTRIBUTED_STORAGE_CONFIG.local[0] == 'RadosGWStorage'">
|
||||
<td>Is Secure:</td>
|
||||
<td>
|
||||
<div class="co-checkbox">
|
||||
<input id="dsc-secure" type="checkbox" ng-model="config.DISTRIBUTED_STORAGE_CONFIG.local[1].is_secure">
|
||||
<label for="dsc-secure">Requires SSL</label>
|
||||
binding="config.DISTRIBUTED_STORAGE_CONFIG.local[1][field.name]"
|
||||
placeholder="{{ field.placeholder }}"
|
||||
ng-if="field.kind == 'text'"></span>
|
||||
<div class="co-checkbox" ng-if="field.kind == 'bool'">
|
||||
<input id="dsc-{{ field.name }}" type="checkbox"
|
||||
ng-model="config.DISTRIBUTED_STORAGE_CONFIG.local[1][field.name]">
|
||||
<label for="dsc-{{ field.name }}">{{ field.placeholder }}</label>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr ng-show="config.DISTRIBUTED_STORAGE_CONFIG.local[0] == 'RadosGWStorage'">
|
||||
<td>Access Key:</td>
|
||||
<td>
|
||||
<span class="config-string-field"
|
||||
binding="config.DISTRIBUTED_STORAGE_CONFIG.local[1].access_key"
|
||||
placeholder="Access key"></span>
|
||||
<div class="help-text">
|
||||
See <a href="http://ceph.com/docs/master/radosgw/admin/" target="_blank">
|
||||
RADOS Documentation
|
||||
</a> for more information
|
||||
<div class="help-text" ng-if="field.help_url">
|
||||
See <a href="{{ field.help_url }}" target="_blank">Documentation</a> for more information
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr ng-show="config.DISTRIBUTED_STORAGE_CONFIG.local[0] == 'RadosGWStorage'">
|
||||
<td>Secret Key:</td>
|
||||
<td>
|
||||
<span class="config-string-field"
|
||||
binding="config.DISTRIBUTED_STORAGE_CONFIG.local[1].secret_key"
|
||||
placeholder="Secret key"></span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr ng-show="config.DISTRIBUTED_STORAGE_CONFIG.local[0] == 'RadosGWStorage'">
|
||||
<td>Bucket Name:</td>
|
||||
<td>
|
||||
<span class="config-string-field"
|
||||
binding="config.DISTRIBUTED_STORAGE_CONFIG.local[1].bucket_name"
|
||||
placeholder="Bucket name"></span>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
<div class="co-panel-button-bar">
|
||||
<button class="btn btn-default"><i class="fa fa-sign-in"></i> Test Configuration</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
@ -375,7 +260,8 @@
|
|||
<tr>
|
||||
<td>Password:</td>
|
||||
<td>
|
||||
<span class="config-string-field" binding="config.MAIL_PASSWORD"
|
||||
<input class="form-control" type="password"
|
||||
ng-model="config.MAIL_PASSWORD"
|
||||
placeholder="Password for authentication"></span>
|
||||
</td>
|
||||
</tr>
|
||||
|
@ -384,9 +270,6 @@
|
|||
</tr>
|
||||
|
||||
</table>
|
||||
<div class="co-panel-button-bar" ng-show="config.FEATURE_MAILING">
|
||||
<button class="btn btn-default"><i class="fa fa-sign-in"></i> Test Configuration</button>
|
||||
</div>
|
||||
</div>
|
||||
</div> <!-- /E-mail -->
|
||||
|
||||
|
@ -446,10 +329,6 @@
|
|||
<td><span class="config-list-field" item-title="RDN" binding="config.LDAP_USER_RDN"></span></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<div class="co-panel-button-bar" ng-show="config.AUTHENTICATION_TYPE == 'LDAP'">
|
||||
<button class="btn btn-default"><i class="fa fa-sign-in"></i> Test Configuration</button>
|
||||
</div>
|
||||
</div>
|
||||
</div> <!-- /Authentication -->
|
||||
|
||||
|
@ -513,11 +392,6 @@
|
|||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
|
||||
<div class="co-panel-button-bar" ng-show="config.FEATURE_GITHUB_LOGIN">
|
||||
<button class="btn btn-default"><i class="fa fa-sign-in"></i> Test Configuration</button>
|
||||
</div>
|
||||
</div>
|
||||
</div> <!-- /Github Authentication -->
|
||||
|
||||
|
@ -562,17 +436,34 @@
|
|||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
|
||||
<div class="co-panel-button-bar" ng-show="config.FEATURE_GOOGLE_LOGIN">
|
||||
<button class="btn btn-default"><i class="fa fa-sign-in"></i> Test Configuration</button>
|
||||
</div>
|
||||
</div>
|
||||
</div> <!-- /Google Authentication -->
|
||||
|
||||
<!-- Build Support -->
|
||||
<div class="co-panel">
|
||||
<div class="co-panel-heading">
|
||||
<i class="fa fa-tasks"></i> Dockerfile Build Support
|
||||
</div>
|
||||
<div class="co-panel-body">
|
||||
<div class="description">
|
||||
If enabled, users can submit Dockerfiles to be built and pushed by the Enterprise Registry.
|
||||
</div>
|
||||
|
||||
<div class="co-checkbox">
|
||||
<input id="ftbs" type="checkbox" ng-model="config.FEATURE_BUILD_SUPPORT">
|
||||
<label for="ftbs">Enable Dockerfile Build</label>
|
||||
</div>
|
||||
|
||||
<div ng-if="config.FEATURE_BUILD_SUPPORT" style="margin-top: 10px">
|
||||
<strong>Note: Build workers are required for this feature.</strong>
|
||||
See <a href="https://coreos.com/docs/enterprise-registry/build-support/" target="_blank">Adding Build Workers</a> for instructions on how to setup build workers.
|
||||
</div>
|
||||
</div>
|
||||
</div> <!-- /Build Support -->
|
||||
|
||||
|
||||
<!-- Github Trigger -->
|
||||
<div class="co-panel" ng-show="config.FEATURE_BUILD_SUPPORT">
|
||||
<div class="co-panel" ng-show="config.FEATURE_BUILD_SUPPORT" style="margin-top: 20px;">
|
||||
<div class="co-panel-heading">
|
||||
<i class="fa fa-github"></i> Github (Enterprise) Build Triggers
|
||||
</div>
|
||||
|
@ -631,12 +522,90 @@
|
|||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
|
||||
<div class="co-panel-button-bar" ng-show="config.FEATURE_GITHUB_BUILD">
|
||||
<button class="btn btn-default"><i class="fa fa-sign-in"></i> Test Configuration</button>
|
||||
</div>
|
||||
</div>
|
||||
</div> <!-- /Github Trigger -->
|
||||
|
||||
<!-- Save Bar -->
|
||||
<div class="cor-floating-bottom-bar">
|
||||
<button class="btn" ng-class="mapped.$hasChanges ? 'btn-primary' : 'btn-success'"
|
||||
ng-click="validateAndSave()">
|
||||
<i class="fa fa-lg" ng-class="mapped.$hasChanges ? 'fa-dot-circle-o' : 'fa-check-circle'"></i>
|
||||
<span ng-if="mapped.$hasChanges">Save Configuration Changes</span>
|
||||
<span ng-if="!mapped.$hasChanges">Configuration Saved</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Modal message dialog -->
|
||||
<div class="modal fade initial-setup-modal" id="validateAndSaveModal">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h4 class="modal-title"
|
||||
ng-show="mapped.$hasChanges && validationStatus(validating) == 'validating'">
|
||||
Validating Configuration... Please Wait
|
||||
</h4>
|
||||
<h4 class="modal-title"
|
||||
ng-show="mapped.$hasChanges && validationStatus(validating) == 'failed'">
|
||||
<i class="fa fa-warning"></i> Configuration Validation Failed
|
||||
</h4>
|
||||
<h4 class="modal-title"
|
||||
ng-show="mapped.$hasChanges && validationStatus(validating) == 'success'">
|
||||
<i class="fa fa-check-circle"></i> Configuration Validation Succeeded!
|
||||
</h4>
|
||||
<h4 class="modal-title" ng-show="!mapped.$hasChanges">
|
||||
Configuration Changes Saved
|
||||
</h4>
|
||||
</div>
|
||||
<div class="modal-body" ng-show="!mapped.$hasChanges">
|
||||
<div class="verified">
|
||||
<i class="fa fa-check-circle"></i> Configuration Changes Saved
|
||||
</div>
|
||||
|
||||
<p>Your configuration changes have been saved and will be applied the next time the <span class="registry-title"></span> container is restarted.</p>
|
||||
|
||||
<p>
|
||||
<strong>
|
||||
It is highly recommended that you restart your container now and test these changes!
|
||||
</strong>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="modal-body" ng-show="mapped.$hasChanges">
|
||||
<div class="service-verification">
|
||||
<div class="service-verification-row" ng-repeat="serviceInfo in validating">
|
||||
<span class="quay-spinner" ng-show="serviceInfo.status == 'validating'"></span>
|
||||
<i class="fa fa-lg fa-check-circle" ng-show="serviceInfo.status == 'success'"></i>
|
||||
<i class="fa fa-lg fa-warning" ng-show="serviceInfo.status == 'error'"></i>
|
||||
<span class="service-title">{{ serviceInfo.service.title }}</span>
|
||||
|
||||
<div class="service-verification-error" ng-show="serviceInfo.status == 'error'">{{ serviceInfo.errorMessage }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer" ng-show="!mapped.$hasChanges">
|
||||
<button class="btn btn-default" data-dismiss="modal">
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer" ng-show="mapped.$hasChanges">
|
||||
<span ng-show="validating.length == 0">Please Wait...</span>
|
||||
<button class="btn btn-primary"
|
||||
ng-show="validationStatus(validating) == 'success'"
|
||||
ng-click="saveConfiguration()"
|
||||
ng-disabled="savingConfiguration">
|
||||
<i class="fa fa-upload" style="margin-right: 10px;"></i>Save Configuration
|
||||
</button>
|
||||
<button class="btn btn-default"
|
||||
ng-show="validationStatus(validating) == 'failed'"
|
||||
data-dismiss="modal">
|
||||
Continue Editing Configuration
|
||||
</button>
|
||||
</div>
|
||||
</div><!-- /.modal-content -->
|
||||
</div><!-- /.modal-dialog -->
|
||||
</div><!-- /.modal -->
|
||||
|
||||
</div>
|
||||
</div>
|
3
static/directives/cor-floating-bottom-bar.html
Normal file
3
static/directives/cor-floating-bottom-bar.html
Normal file
|
@ -0,0 +1,3 @@
|
|||
<div class="co-floating-bottom-bar">
|
||||
<span ng-transclude/>
|
||||
</div>
|
|
@ -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) {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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:
|
||||
|
|
0
util/config/__init__.py
Normal file
0
util/config/__init__.py
Normal file
122
util/config/validator.py
Normal file
122
util/config/validator.py
Normal file
|
@ -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,
|
||||
}
|
|
@ -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&'
|
||||
|
||||
|
|
Reference in a new issue