Merge pull request #573 from vincent99/ha

HA UI
This commit is contained in:
Vincent Fiduccia 2016-04-06 19:48:54 -07:00
commit a9df09e6c3
12 changed files with 579 additions and 22 deletions

View File

@ -0,0 +1,206 @@
import Ember from 'ember';
import Util from 'ui/utils/util';
import C from 'ui/utils/constants';
export default Ember.Controller.extend({
settings: Ember.inject.service(),
growl: Ember.inject.service(),
cookies: Ember.inject.service(),
projects: Ember.inject.service(),
csrf: Ember.computed.alias('cookies.CSRF'),
userUrl: '',
selfSign: true,
generating: false,
justGenerated: false,
errors: null,
confirmPanic: false,
haProject: null,
configExecute: function() {
return 'sudo bash ./rancher-ha.sh rancher/server:' + (this.get('settings.rancherVersion') || 'latest');
}.property('settings.rancherVersion'),
runCode: function() {
let version = this.get('settings.rancherVersion') || 'latest';
return `sudo docker run -d --restart=always -p 8080:8080 \\
-e CATTLE_DB_CATTLE_MYSQL_HOST=<hostname or IP of MySQL instance> \\
-e CATTLE_DB_CATTLE_MYSQL_PORT=<port> \\
-e CATTLE_DB_CATTLE_MYSQL_NAME=<Name of database> \\
-e CATTLE_DB_CATTLE_USERNAME=<Username> \\
-e CATTLE_DB_CATTLE_PASSWORD=<Password> \\
rancher/server:${version}`;
}.property('settings.rancherVersion'),
isLocalDb: function() {
return (this.get('model.haConfig.dbHost')||'').toLowerCase() === 'localhost';
}.property('model.haConfig.dbHost'),
actions: {
exportDatabase() {
Util.download(this.get('model.haConfig').linkFor('dbdump'));
},
readFile(field, text) {
this.set('model.createScript.'+field, text.trim());
},
promptPanic() {
this.set('confirmPanic', true);
Ember.run.later(() => {
if ( this._state !== 'destroying' )
{
this.set('confirmPanic', false);
}
}, 5000);
},
panic() {
var orig = this.get('model.haConfig');
var clone = orig.clone();
clone.set('enabled', false);
clone.save({headers: {[C.HEADER.PROJECT]: undefined}}).then(() => {
orig.set('enabled', false);
}).catch((err) => {
this.get('growl').fromError(err);
});
},
generateConfig() {
if ( !this.validate() ) {
return;
}
this.set('generating',true);
this.set('haProject', null);
Ember.run.later(() => {
this.set('generating',false);
this.set('justGenerated',true);
}, 500);
},
downloadConfig() {
var form = $('#haConfigForm')[0];
form.submit();
this.set('downloaded',true);
var ha = this.get('model.haConfig');
var clone = ha.clone();
clone.set('enabled',true);
clone.save({headers: {[C.HEADER.PROJECT]: undefined}}).then((neu) => {
ha.merge(neu);
this.findProject();
});
},
},
userUrlChanged: function() {
let val = this.get('userUrl')||'';
let match = val.match(/^https?:\/\//);
if ( match )
{
val = val.substr(match[0].length);
}
if ( match = val.match(/^(.*):(\d+)$/) )
{
let port = parseInt(match[2],10);
if ( port > 0 )
{
this.set('model.httpsPort', port);
}
}
let pos = val.indexOf('/',1);
if ( pos >= 1 && val.substr(pos-2,2) !== ':/' && val.substr(pos-2,2) !== 's:' )
{
val = val.substr(0,pos);
}
this.set('userUrl', val);
this.set('model.createScript.hostRegistrationUrl', 'https://'+val);
}.observes('userUrl'),
selfSignChanged: function() {
if ( this.get('selfSign') )
{
this.get('model.createScript').setProperties({
key: null,
cert: null,
certChain: null,
});
}
}.observes('selfSign'),
validate() {
var errors = this.get('model.createScript').validationErrors();
this.set('errors',errors);
return errors.length === 0;
},
findProject: function() {
this.get('store').find('project', null, {authAsUser: true, filter: {all: true}, forceReload: true}).then((projects) => {
var matches = projects.filter((project) => {
return project.get('uuid').match(/^system-ha-(\d+)$/) || project.get('uuid').match(/^system-management-(\d+)$/);
});
if ( matches.length )
{
this.set('haProject', matches.objectAt(0));
if ( this.get('projects.current.id') === this.get('haProject.id') )
{
this.getHosts();
}
else
{
this.send('switchProject', this.get('haProject.id'), false);
}
}
else
{
Ember.run.later(this,'findProject', 5000);
}
});
},
hosts: null,
getHosts: function() {
return this.get('store').findAll('host', null, {forceReload: true}).then((hosts) => {
this.set('hosts', hosts);
});
}.observes('haProject'),
expectedHosts: Ember.computed.alias('model.haConfig.clusterSize'),
activeHosts: function() {
return (this.get('hosts')||[]).filterBy('state','active').get('length');
}.property('hosts.@each.state'),
hostBlurb: function() {
clearInterval(this.get('hostTimer'));
var total = this.get('expectedHosts');
var active = this.get('activeHosts');
if ( active < total )
{
this.set('hostTimer', setInterval(() => {
this.getHosts();
}, 5000));
return active + '/' + total;
}
else
{
return total;
}
}.property('hosts.@each.state','model.haConfig.clusterSize'),
cert: null,
getCertificate: function() {
return this.get('store').find('certificate', null, {filter: {name: 'system-ssl'}}).then((certs) => {
this.set('cert', certs.objectAt(0));
this.getHosts();
});
}.observes('haProject'),
});

27
app/admin-tab/ha/route.js Normal file
View File

@ -0,0 +1,27 @@
import Ember from 'ember';
export default Ember.Route.extend({
settings: Ember.inject.service(),
beforeModel() {
return Ember.RSVP.all([
this.get('store').find('schema','haconfig', {authAsUser: true}),
this.get('store').find('schema','haconfiginput', {authAsUser: true}),
]);
},
model() {
var store = this.get('store');
return store.find('haConfig', null, {authAsUser: true, forceReload: true}).then((res) => {
return Ember.Object.create({
haConfig: res.objectAt(0),
createScript: store.createRecord({type: 'haConfigInput'})
});
});
},
setupController(controller/*, model*/) {
this._super(...arguments);
controller.findProject();
}
});

View File

@ -0,0 +1,312 @@
<section class="well">
<h2>High Availability is {{#if model.haConfig.enabled}}<b class="text-success">enabled</b>{{else}}<b class="text-warning">not configured</b>{{/if}}</h2>
</section>
{{#if model.haConfig.enabled}}
{{#if (lt activeHosts expectedHosts)}}
<section class="well">
<h4>5. Add hosts</h4>
<hr/>
<p>
Copy the downloaded script and run it on to each HA host to register them:
{{code-block code=configExecute language="bash" constrained=false}}
</p>
</section>
{{/if}}
<div class="row">
<div class="col-md-3">
<div class="well">
<h4 class="r-mb10">Hosts:</h4>
{{#if hosts}}
<h1 class="text-center">{{hostBlurb}}</h1>
{{else}}
<i class="icon icon-spinner icon-spin"></i> Waiting for a host...
{{/if}}
</div>
</div>
<div class="col-md-9">
<div class="well">
<h4 class="r-mb10">Management Server Certificate: {{#if cert.cert}}{{copy-to-clipboard size="small" clipboardText=cert.cert}}{{/if}}</h4>
{{#if cert.cert}}
<pre><code>{{cert.cert}}</code></pre>
{{else}}
<i class="icon icon-spinner icon-spin"></i> Waiting for a host...
{{/if}}
</div>
</div>
</div>
<section class="well">
<h4>Danger Zone&trade;</h4>
<hr/>
<p>
{{#if confirmPanic}}
<button class="btn btn-danger" {{action "panic"}}>
<i class="icon icon-alert"></i> Are you sure? Click again to really disable access HA
</button>
{{else}}
<button class="btn btn-danger" {{action "promptPanic"}}>
<i class="icon icon-umbrella"></i> Disable HA
</button>
{{/if}}
</p>
</section>
{{else}}
<section class="well">
{{#if isLocalDb}}
<h4>1. Setup an external database</h4>
<hr/>
<p>
This {{settings.appName}} installation is currently configured to use the built-in database server, but HA requires a standalone installation of MySQL.
</p>
<p>
<ul>
<li>
Setup an external database instance.
<ul>
<li>This can be a hosted solution like Amazon RDS or Google Cloud SQL,</li>
<li>Or a self-hosted instance or multi-master cluster.</li>
</ul>
</li>
<li>Click the export button below to export the entire contents of the current database.</li>
<li>Import the data into the new external database.</li>
</ul>
</p>
<p class="r-mb0 r-mt20">
<button class="btn btn-primary" {{action "exportDatabase"}}>Export Database</button>
<p class="help-block r-mb0">Uncompressed Size: {{format-mib model.haConfig.dbSize}}</p>
</p>
{{else}}
<h4 class="text-success">1. Setup an external database <i class="icon icon-check"/></h4>
<p class="text-muted r-mb0">
Complete, running off of an external database.
</p>
{{/if}}
</section>
<section class="well">
{{#if isLocalDb}}
<h4>2. Use the new external database</h4>
<hr/>
<ul>
<li>Re-launch the server container pointed at the external database:
{{code-block code=runCode language="bash" constrained=false}}
</li>
{{#unless settings.isPrivateLabel}}
<li>
<a href="http://docs.rancher.com/rancher/installing-rancher/installing-server/#using-an-external-database" target="_blank">See docs</a> for more detail.
</li>
{{/unless}}
</ul>
{{else}}
<h4 class="text-success">2. Use the new external database <i class="icon icon-check"/></h4>
<p class="text-muted r-mb0">
Complete, <code>{{model.haConfig.dbHost}}</code> will be used as the external database for HA.
</p>
{{/if}}
</section>
<section class="well">
{{#if justGenerated}}
<h4 class="text-success">3. Generate HA config script <i class="icon icon-check"/></h4>
<p class="text-muted r-mb0">
Complete.
</p>
{{else}}
<h4>3. Generate HA config script</h4>
<hr/>
{{#if isLocalDb}}
<p class="text-muted">Come back here once you are running off the external database...</p>
{{/if}}
{{/if}}
<iframe name="haConfigFrame" id="haConfigFrame" style="display: none;"></iframe>
<form action="{{model.haConfig.actionLinks.createscript}}" method="POST" id="haConfigForm" target="haConfigFrame" class="{{if (or isLocalDb justGenerated) 'hide'}}">
<input type="hidden" name="hostRegistrationUrl" value="{{model.createScript.hostRegistrationUrl}}"/>
<input type="hidden" name="CSRF" value="{{csrf}}"/>
<div class="row form-group">
<div class="col-sm-6 col-md-2 form-control-static">
<label>Cluster Size</label>
</div>
<div class="col-sm-6 col-md-10">
<div class="radio">
<label>
{{radio-button name="clusterSize" selection=model.createScript.clusterSize value=1}}
<span class="text-bold" style="display: inline-block: width: 120px;">1 Host:</span> Not really very HA at all
</label>
</div>
<div class="radio">
<label>
{{radio-button name="clusterSize" selection=model.createScript.clusterSize value=3}}
<span class="text-bold" style="display: inline-block: width: 120px;">3 Hosts:</span> Any <b>one</b> host can fail
</label>
</div>
<div class="radio">
<label>
{{radio-button name="clusterSize" selection=model.createScript.clusterSize value=5}}
<span class="text-bold" style="display: inline-block: width: 120px;">5 Hosts:</span> Any <b>two</b> hosts can fail
</label>
</div>
</div>
</div>
<div class="row form-group">
<div class="col-sm-6 col-md-2 form-control-static">
<label>Host Registration URL</label>
</div>
<div class="col-sm-6 col-md-10">
<div class="input-group">
<span class="input-group-addon">
https://
</span>
{{input type="text" value=userUrl type="text" classNames="form-control"}}
</div>
<p class="help-block">This should be a FQDN that resolves to the addresses of or is a load balancer for {{#if (eq model.createScript.clusterSize 1)}}the HA host{{else}}all {{model.createScript.clusterSize}} HA hosts{{/if}}. Do not include <code>/v1</code> or any other path.</p>
</div>
</div>
<div class="row form-group">
<div class="col-sm-6 col-md-2 form-control-static">
<label>Certificate</label>
</div>
<div class="col-sm-6 col-md-10">
<div class="radio">
<label>
{{radio-button selection=selfSign value=true}} Generate a self-signed certificate
</label>
</div>
<div class="radio">
<label>
{{radio-button selection=selfSign value=false}} Upload a valid certificate{{#if model.createScript.hostRegistrationUrl}} for <code>{{model.createScript.hostRegistrationUrl}}</code>{{/if}}
</label>
</div>
</div>
</div>
{{#liquid-if (not selfSign)}}
<div class="row form-group">
<div class="col-sm-12 col-md-4">
<div class="clearfix r-mb10 r-mt15">
<label class="r-pt5">Private Key</label>
<span class="pull-right">{{read-text-file accept="text/*, .pem, .pkey, .key" action=(action "readFile" "key")}}</span>
</div>
{{textarea name="key" value=model.createScript.key classNames="form-control no-resize" rows="5" placeholder="Paste in the private key, starting with -----BEGIN RSA PRIVATE KEY-----"}}
</div>
<div class="col-sm-12 col-md-4">
<div class="clearfix r-mb10 r-mt15">
<label class="r-pt5">Certificate</label>
<span class="pull-right">{{read-text-file accept="text/*, .pem, .crt" action=(action "readFile" "cert")}}</span>
</div>
{{textarea name="cert" value=model.createScript.cert classNames="form-control no-resize" rows="5" placeholder="Paste in the primary certificate, starting with -----BEGIN CERTIFICATE-----"}}
</div>
<div class="col-sm-12 col-md-4">
<div class="clearfix r-mb10 r-mt15">
<label class="r-pt5">Chain Certs</label>
<span class="pull-right">{{read-text-file accept="text/*, .pem, .crt" action=(action "readFile" "certChain")}}</span>
</div>
{{textarea name="certChain" value=model.createScript.certChain classNames="form-control no-resize" rows="5" placeholder="Optional; Paste in the additional chained certificates, starting with -----BEGIN CERTIFICATE-----"}}
</div>
</div>
{{/liquid-if}}
{{#advanced-section}}
<div class="row">
<div class="col-sm-6 col-md-2 form-control-static">
<label>Listening Ports</label>
</div>
<div class="col-sm-6 col-md-5 r-pt5">
<table class="fixed">
<tbody>
<tr>
<td width="200">HTTPS</td>
<td width="100" class="text-muted">Required</td>
<td width="100">{{input name="httpsPort" class="form-control input-sm r-mb5" type="number" min=1 max=65535 value=model.createScript.httpsPort}}</td>
</tr>
<tr>
<td>HTTP</td>
<!-- <td><label>{{input name="httpEnabled" type="checkbox" checked=model.createScript.httpEnabled}} Enable</label></td> -->
<td width="100" class="text-muted">Required</td>
<td>{{input name="httpPort" class="form-control input-sm r-mb5" type="number" min=1 max=65535 value=model.createScript.httpPort disabled=(not model.createScript.httpEnabled)}}</td>
</tr>
<tr>
<td>Swarm</td>
<!-- <td><label>{{input name="swarmEnabled" type="checkbox" checked=model.createScript.swarmEnabled}} Enable</label></td> -->
<td width="100" class="text-muted">Required</td>
<td>{{input name="swarmPort" class="form-control input-sm r-mb5" type="number" min=1 max=65535 value=model.createScript.swarmPort disabled=(not model.createScript.swarmEnabled)}}</td>
</tr>
</tbody>
</table>
</div>
<div class="col-sm-6 col-md-5 r-pt5">
<table class="fixed">
<tbody>
<tr class="r-mb5">
<td width="200">Redis</td>
<td width="100" class="text-muted">Required</td>
<td width="100">{{input name="redisPort" class="form-control input-sm r-mb5" type="number" min=1 max=65535 value=model.createScript.redisPort}}</td>
</tr>
<tr>
<td>ZooKeeper Client</td>
<td class="text-muted">Required</td>
<td>{{input name="zookeeperClientPort" class="form-control input-sm r-mb5" type="number" min=1 max=65535 value=model.createScript.zookeeperClientPort}}</td>
</tr>
<tr>
<td>ZooKeeper Quorum</td>
<td class="text-muted">Required</td>
<td>{{input name="zookeeperQuorumPort" class="form-control input-sm r-mb5" type="number" min=1 max=65535 value=model.createScript.zookeeperQuorumPort}}</td>
</tr>
<tr>
<td>ZooKeeper Leader</td>
<td class="text-muted">Required</td>
<td>{{input name="zookeeperLeaderPort" class="form-control input-sm r-mb5" type="number" min=1 max=65535 value=model.createScript.zookeeperLeaderPort}}</td>
</tr>
</tbody>
</table>
</div>
</div>
{{/advanced-section}}
<p class="r-mb0 r-mt20">
{{top-errors errors=errors}}
{{#if generating}}
<button class="btn btn-primary btn-disabled" type="button" disabled><i class="icon icon-spinner icon-spin"/> Generating...</button>
{{else}}
<button class="btn btn-primary" type="button" {{action "generateConfig"}}>Generate Config Script</button>
{{/if}}
</p>
</form>
</section>
<section class="well">
{{#if downloaded}}
<h4 class="text-success">4. Download script <i class="icon icon-check"/></h4>
<p class="text-muted r-mb0">
Complete, check your Downloads folder.
</p>
{{else}}
<h4>4. Download script</h4>
<hr/>
{{#if justGenerated}}
<p>
Click the button below to download a shell script.
<div class="alert alert-info">
The script generates new encryption keys that will be used for the HA hosts to communicate with each other, so keep it safe.
New keys are generated every time you download the config script, and all hosts must have the same keys for HA to work.
</div>
</p>
<button class="btn btn-primary" type="button" {{action "downloadConfig"}}>Download Config Script</button>
{{else}}
<p class="text-muted r-mb0">
Generate the script in step 3.
</p>
{{/if}}
{{/if}}
</section>
{{/if}}

View File

@ -205,9 +205,11 @@ export default Ember.Route.extend(Subscribe, {
this.controllerFor('application').set('showAbout', true);
},
switchProject(projectId) {
switchProject(projectId, transition=true) {
this.reset();
this.intermediateTransitionTo('authenticated');
if ( transition ) {
this.intermediateTransitionTo('authenticated');
}
this.set(`tab-session.${C.TABSESSION.PROJECT}`, projectId);
this.refresh();
},

View File

@ -4,10 +4,11 @@ export default Ember.Component.extend({
language: 'javascript',
code: '',
hide: false,
constrained: true,
tagName: 'PRE',
classNames: ['line-numbers','constrained'],
classNameBindings: ['languageClass','hide:hide'],
classNames: ['line-numbers'],
classNameBindings: ['languageClass','hide:hide','constrained:constrained'],
languageClass: function() {
var lang = this.get('language');

View File

@ -47,6 +47,7 @@ Router.map(function() {
});
this.route('audit-logs');
this.route('ha');
});
this.route('project', {path: '/env/:project_id'}, function() {

View File

@ -6,7 +6,7 @@ export function normalizeName(str) {
}
export function denormalizeName(str) {
return str.replace(C.SETTING.DOT_CHAR,'.').toLowerCase();
return str.replace(new RegExp('['+C.SETTING.DOT_CHAR+']','g'),'.').toLowerCase();
}
export default Ember.Service.extend(Ember.Evented, {
@ -85,6 +85,12 @@ export default Ember.Service.extend(Ember.Evented, {
return this.get('asMap')[normalizeName(name)];
},
findAsUser(key) {
return this.get('store').find('setting', denormalizeName(key), {authAsUser: true, forceReload: true}).then(() => {
return Ember.RSVP.resolve(this.unknownProperty(key));
});
},
asMap: function() {
var out = {};
(this.get('all')||[]).forEach((setting) => {

View File

@ -11,7 +11,7 @@
<div>
<label class="text-muted r-mt10">Image: </label>
<span class="force-wrap">
{{service.launchConfig.imageUuid}}
{{service.launchConfig.displayImage}}
</span>
</div>
<div>

View File

@ -3,3 +3,4 @@
{{#link-to "admin-tab.accounts"}}<i class="icon icon-users"></i>Accounts{{/link-to}}
{{#link-to "admin-tab.auth"}}<i class="icon icon-key"></i>Access Control{{/link-to}}
{{#link-to "admin-tab.settings"}}<i class="icon icon-network"></i>Settings{{/link-to}}
{{#link-to "admin-tab.ha"}}<i class="icon icon-umbrella"></i>HA{{/link-to}}

View File

@ -166,23 +166,23 @@ var C = {
SETTING: {
// Dots in key names do not mix well with Ember, so use $ in their place.
DOT_CHAR: '$',
VERSION_RANCHER: 'rancher$server$image',
VERSION_COMPOSE: 'rancher$compose$version',
VERSION_CATTLE: 'cattle$version',
VERSION_MACHINE: 'docker$machine$version',
VERSION_GMS: 'go$machine$service$version',
DOT_CHAR: '$',
VERSION_RANCHER: 'rancher$server$image',
VERSION_COMPOSE: 'rancher$compose$version',
VERSION_CATTLE: 'cattle$version',
VERSION_MACHINE: 'docker$machine$version',
VERSION_GMS: 'go$machine$service$version',
COMPOSE_URL: {
DARWIN: 'rancher$compose$darwin$url',
WINDOWS: 'rancher$compose$windows$url',
LINUX: 'rancher$compose$linux$url',
DARWIN: 'rancher$compose$darwin$url',
WINDOWS: 'rancher$compose$windows$url',
LINUX: 'rancher$compose$linux$url',
},
API_HOST: 'api$host',
CATALOG_URL: 'catalog$url',
VM_ENABLED: 'vm$enabled',
HELP_ENABLED: 'help$enabled',
SWARM_PORT: 'swarm$tls$port',
ENGINE_URL: 'engine$install$url'
API_HOST: 'api$host',
CATALOG_URL: 'catalog$url',
VM_ENABLED: 'vm$enabled',
HELP_ENABLED: 'help$enabled',
SWARM_PORT: 'swarm$tls$port',
ENGINE_URL: 'engine$install$url',
},
USER: {

View File

@ -1,6 +1,6 @@
{
"name": "ui",
"version": "0.100.4",
"version": "1.0.1",
"private": true,
"directories": {
"doc": "doc",

View File

@ -10,6 +10,7 @@ module.exports = function(app, options) {
ws: true,
xfwd: false,
target: config.apiServer,
secure: false,
});
proxy.on('error', onProxyError);