Merge branch 'master' into f-tls-parse-certs

This commit is contained in:
Alex Dadgar 2018-05-30 17:25:50 +00:00 committed by GitHub
commit 446fc64850
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
55 changed files with 1035 additions and 104 deletions

View File

@ -3,6 +3,7 @@
IMPROVEMENTS:
* core: Updated serf library to improve how leave intents are handled [[GH-4278](https://github.com/hashicorp/nomad/issues/4278)]
* core: Add more descriptive errors when parsing agent TLS certificates [[GH-4340](https://github.com/hashicorp/nomad/issues/4340)]
* core: Added TLS configuration option to prefer server's ciphersuites over clients[[GH-4338](https://github.com/hashicorp/nomad/issues/4338)]
* core: Add the option for operators to configure TLS versions and allowed
cipher suites. Default is a subset of safe ciphers and TLS 1.2 [[GH-4269](https://github.com/hashicorp/nomad/pull/4269)]
* core: Add a new [progress_deadline](https://www.nomadproject.io/docs/job-specification/update.html#progress_deadline) parameter to

View File

@ -162,8 +162,8 @@ type UniversalExecutor struct {
processExited chan interface{}
fsIsolationEnforced bool
lre *logging.FileRotator
lro *logging.FileRotator
lre *logRotatorWrapper
lro *logRotatorWrapper
rotatorLock sync.Mutex
syslogServer *logging.SyslogServer
@ -252,8 +252,8 @@ func (e *UniversalExecutor) LaunchCmd(command *ExecCommand) (*ProcessState, erro
if err := e.configureLoggers(); err != nil {
return nil, err
}
e.cmd.Stdout = e.lro
e.cmd.Stderr = e.lre
e.cmd.Stdout = e.lro.processOutWriter
e.cmd.Stderr = e.lre.processOutWriter
// Look up the binary path and make it executable
absPath, err := e.lookupBin(e.ctx.TaskEnv.ReplaceEnv(command.Cmd))
@ -348,7 +348,12 @@ func (e *UniversalExecutor) configureLoggers() error {
if err != nil {
return fmt.Errorf("error creating new stdout log file for %q: %v", e.ctx.Task.Name, err)
}
e.lro = lro
r, err := NewLogRotatorWrapper(lro)
if err != nil {
return err
}
e.lro = r
}
if e.lre == nil {
@ -357,7 +362,12 @@ func (e *UniversalExecutor) configureLoggers() error {
if err != nil {
return fmt.Errorf("error creating new stderr log file for %q: %v", e.ctx.Task.Name, err)
}
e.lre = lre
r, err := NewLogRotatorWrapper(lre)
if err != nil {
return err
}
e.lre = r
}
return nil
}
@ -375,14 +385,14 @@ func (e *UniversalExecutor) UpdateLogConfig(logConfig *structs.LogConfig) error
if e.lro == nil {
return fmt.Errorf("log rotator for stdout doesn't exist")
}
e.lro.MaxFiles = logConfig.MaxFiles
e.lro.FileSize = int64(logConfig.MaxFileSizeMB * 1024 * 1024)
e.lro.rotatorWriter.MaxFiles = logConfig.MaxFiles
e.lro.rotatorWriter.FileSize = int64(logConfig.MaxFileSizeMB * 1024 * 1024)
if e.lre == nil {
return fmt.Errorf("log rotator for stderr doesn't exist")
}
e.lre.MaxFiles = logConfig.MaxFiles
e.lre.FileSize = int64(logConfig.MaxFileSizeMB * 1024 * 1024)
e.lre.rotatorWriter.MaxFiles = logConfig.MaxFiles
e.lre.rotatorWriter.FileSize = int64(logConfig.MaxFileSizeMB * 1024 * 1024)
return nil
}
@ -393,10 +403,10 @@ func (e *UniversalExecutor) UpdateTask(task *structs.Task) error {
e.rotatorLock.Lock()
if e.lro != nil && e.lre != nil {
fileSize := int64(task.LogConfig.MaxFileSizeMB * 1024 * 1024)
e.lro.MaxFiles = task.LogConfig.MaxFiles
e.lro.FileSize = fileSize
e.lre.MaxFiles = task.LogConfig.MaxFiles
e.lre.FileSize = fileSize
e.lro.rotatorWriter.MaxFiles = task.LogConfig.MaxFiles
e.lro.rotatorWriter.FileSize = fileSize
e.lre.rotatorWriter.MaxFiles = task.LogConfig.MaxFiles
e.lre.rotatorWriter.FileSize = fileSize
}
e.rotatorLock.Unlock()
return nil
@ -799,7 +809,7 @@ func (e *UniversalExecutor) LaunchSyslogServer() (*SyslogServerState, error) {
e.syslogServer = logging.NewSyslogServer(l, e.syslogChan, e.logger)
go e.syslogServer.Start()
go e.collectLogs(e.lre, e.lro)
go e.collectLogs(e.lre.rotatorWriter, e.lro.rotatorWriter)
syslogAddr := fmt.Sprintf("%s://%s", l.Addr().Network(), l.Addr().String())
return &SyslogServerState{Addr: syslogAddr}, nil
}
@ -809,11 +819,54 @@ func (e *UniversalExecutor) collectLogs(we io.Writer, wo io.Writer) {
// If the severity of the log line is err then we write to stderr
// otherwise all messages go to stdout
if logParts.Severity == syslog.LOG_ERR {
e.lre.Write(logParts.Message)
e.lre.Write([]byte{'\n'})
we.Write(logParts.Message)
we.Write([]byte{'\n'})
} else {
e.lro.Write(logParts.Message)
e.lro.Write([]byte{'\n'})
wo.Write(logParts.Message)
wo.Write([]byte{'\n'})
}
}
}
// logRotatorWrapper wraps our log rotator and exposes a pipe that can feed the
// log rotator data. The processOutWriter should be attached to the process and
// data will be copied from the reader to the rotator.
type logRotatorWrapper struct {
processOutWriter *os.File
processOutReader *os.File
rotatorWriter *logging.FileRotator
}
// NewLogRotatorWrapper takes a rotator and returns a wrapper that has the
// processOutWriter to attach to the processes stdout or stderr.
func NewLogRotatorWrapper(rotator *logging.FileRotator) (*logRotatorWrapper, error) {
r, w, err := os.Pipe()
if err != nil {
return nil, fmt.Errorf("failed to create os.Pipe for extracting logs: %v", err)
}
wrap := &logRotatorWrapper{
processOutWriter: w,
processOutReader: r,
rotatorWriter: rotator,
}
wrap.start()
return wrap, nil
}
// start starts a go-routine that copies from the pipe into the rotator. This is
// called by the constructor and not the user of the wrapper.
func (l *logRotatorWrapper) start() {
go func() {
io.Copy(l.rotatorWriter, l.processOutReader)
l.processOutReader.Close() // in case io.Copy stopped due to write error
}()
return
}
// Close closes the rotator and the process writer to ensure that the Wait
// command exits.
func (l *logRotatorWrapper) Close() error {
l.rotatorWriter.Close()
return l.processOutWriter.Close()
}

View File

@ -156,6 +156,9 @@ tls {
key_file = "pipe"
rpc_upgrade_mode = true
verify_https_client = true
tls_prefer_server_cipher_suites = true
tls_cipher_suites = "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256"
tls_min_version = "tls12"
}
sentinel {
import "foo" {

View File

@ -763,6 +763,7 @@ func parseTLSConfig(result **config.TLSConfig, list *ast.ObjectList) error {
"verify_https_client",
"tls_cipher_suites",
"tls_min_version",
"tls_prefer_server_cipher_suites",
}
if err := helper.CheckHCLKeys(listVal, valid); err != nil {

View File

@ -167,14 +167,17 @@ func TestConfig_Parse(t *testing.T) {
Token: "12345",
},
TLSConfig: &config.TLSConfig{
EnableHTTP: true,
EnableRPC: true,
VerifyServerHostname: true,
CAFile: "foo",
CertFile: "bar",
KeyFile: "pipe",
RPCUpgradeMode: true,
VerifyHTTPSClient: true,
EnableHTTP: true,
EnableRPC: true,
VerifyServerHostname: true,
CAFile: "foo",
CertFile: "bar",
KeyFile: "pipe",
RPCUpgradeMode: true,
VerifyHTTPSClient: true,
TLSPreferServerCipherSuites: true,
TLSCipherSuites: "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256",
TLSMinVersion: "tls12",
},
HTTPAPIResponseHeaders: map[string]string{
"Access-Control-Allow-Origin": "*",

View File

@ -106,6 +106,12 @@ type Config struct {
// these values for acceptable safe alternatives.
CipherSuites []uint16
// PreferServerCipherSuites controls whether the server selects the
// client's most preferred ciphersuite, or the server's most preferred
// ciphersuite. If true then the server's preference, as expressed in
// the order of elements in CipherSuites, is used.
PreferServerCipherSuites bool
// MinVersion contains the minimum SSL/TLS version that is accepted.
MinVersion uint16
}
@ -122,15 +128,16 @@ func NewTLSConfiguration(newConf *config.TLSConfig, verifyIncoming, verifyOutgoi
}
return &Config{
VerifyIncoming: verifyIncoming,
VerifyOutgoing: verifyOutgoing,
VerifyServerHostname: newConf.VerifyServerHostname,
CAFile: newConf.CAFile,
CertFile: newConf.CertFile,
KeyFile: newConf.KeyFile,
KeyLoader: newConf.GetKeyLoader(),
CipherSuites: ciphers,
MinVersion: minVersion,
VerifyIncoming: verifyIncoming,
VerifyOutgoing: verifyOutgoing,
VerifyServerHostname: newConf.VerifyServerHostname,
CAFile: newConf.CAFile,
CertFile: newConf.CertFile,
KeyFile: newConf.KeyFile,
KeyLoader: newConf.GetKeyLoader(),
CipherSuites: ciphers,
MinVersion: minVersion,
PreferServerCipherSuites: newConf.TLSPreferServerCipherSuites,
}, nil
}
@ -212,10 +219,11 @@ func (c *Config) OutgoingTLSConfig() (*tls.Config, error) {
}
// Create the tlsConfig
tlsConfig := &tls.Config{
RootCAs: x509.NewCertPool(),
InsecureSkipVerify: true,
CipherSuites: c.CipherSuites,
MinVersion: c.MinVersion,
RootCAs: x509.NewCertPool(),
InsecureSkipVerify: true,
CipherSuites: c.CipherSuites,
MinVersion: c.MinVersion,
PreferServerCipherSuites: c.PreferServerCipherSuites,
}
if c.VerifyServerHostname {
tlsConfig.InsecureSkipVerify = false
@ -332,10 +340,11 @@ func WrapTLSClient(conn net.Conn, tlsConfig *tls.Config) (net.Conn, error) {
func (c *Config) IncomingTLSConfig() (*tls.Config, error) {
// Create the tlsConfig
tlsConfig := &tls.Config{
ClientCAs: x509.NewCertPool(),
ClientAuth: tls.NoClientCert,
CipherSuites: c.CipherSuites,
MinVersion: c.MinVersion,
ClientCAs: x509.NewCertPool(),
ClientAuth: tls.NoClientCert,
CipherSuites: c.CipherSuites,
MinVersion: c.MinVersion,
PreferServerCipherSuites: c.PreferServerCipherSuites,
}
// Parse the CA cert if any

View File

@ -296,6 +296,60 @@ func TestConfig_OutgoingTLS_WithKeyPair(t *testing.T) {
assert.NotNil(cert)
}
func TestConfig_OutgoingTLS_PreferServerCipherSuites(t *testing.T) {
require := require.New(t)
{
conf := &Config{
VerifyOutgoing: true,
CAFile: cacert,
}
tlsConfig, err := conf.OutgoingTLSConfig()
require.Nil(err)
require.Equal(tlsConfig.PreferServerCipherSuites, false)
}
{
conf := &Config{
VerifyOutgoing: true,
CAFile: cacert,
PreferServerCipherSuites: true,
}
tlsConfig, err := conf.OutgoingTLSConfig()
require.Nil(err)
require.Equal(tlsConfig.PreferServerCipherSuites, true)
}
}
func TestConfig_OutgoingTLS_TLSCipherSuites(t *testing.T) {
require := require.New(t)
{
defaultCiphers := []uint16{
tls.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305,
tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,
}
conf := &Config{
VerifyOutgoing: true,
CAFile: cacert,
CipherSuites: defaultCiphers,
}
tlsConfig, err := conf.OutgoingTLSConfig()
require.Nil(err)
require.Equal(tlsConfig.CipherSuites, defaultCiphers)
}
{
conf := &Config{
VerifyOutgoing: true,
CAFile: cacert,
CipherSuites: []uint16{tls.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305},
}
tlsConfig, err := conf.OutgoingTLSConfig()
require.Nil(err)
require.Equal(tlsConfig.CipherSuites, []uint16{tls.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305})
}
}
func startTLSServer(config *Config) (net.Conn, chan error) {
errc := make(chan error, 1)
@ -545,6 +599,51 @@ func TestConfig_IncomingTLS_NoVerify(t *testing.T) {
}
}
func TestConfig_IncomingTLS_PreferServerCipherSuites(t *testing.T) {
require := require.New(t)
{
conf := &Config{}
tlsConfig, err := conf.IncomingTLSConfig()
require.Nil(err)
require.Equal(tlsConfig.PreferServerCipherSuites, false)
}
{
conf := &Config{
PreferServerCipherSuites: true,
}
tlsConfig, err := conf.IncomingTLSConfig()
require.Nil(err)
require.Equal(tlsConfig.PreferServerCipherSuites, true)
}
}
func TestConfig_IncomingTLS_TLSCipherSuites(t *testing.T) {
require := require.New(t)
{
defaultCiphers := []uint16{
tls.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305,
tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,
}
conf := &Config{
CipherSuites: defaultCiphers,
}
tlsConfig, err := conf.IncomingTLSConfig()
require.Nil(err)
require.Equal(tlsConfig.CipherSuites, defaultCiphers)
}
{
conf := &Config{
CipherSuites: []uint16{tls.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305},
}
tlsConfig, err := conf.IncomingTLSConfig()
require.Nil(err)
require.Equal(tlsConfig.CipherSuites, []uint16{tls.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305})
}
}
func TestConfig_ParseCiphers_Valid(t *testing.T) {
require := require.New(t)

View File

@ -63,6 +63,12 @@ type TLSConfig struct {
// TLSMinVersion is used to set the minimum TLS version used for TLS
// connections. Should be either "tls10", "tls11", or "tls12".
TLSMinVersion string `mapstructure:"tls_min_version"`
// TLSPreferServerCipherSuites controls whether the server selects the
// client's most preferred ciphersuite, or the server's most preferred
// ciphersuite. If true then the server's preference, as expressed in
// the order of elements in CipherSuites, is used.
TLSPreferServerCipherSuites bool `mapstructure:"tls_prefer_server_cipher_suites"`
}
type KeyLoader struct {
@ -158,6 +164,8 @@ func (t *TLSConfig) Copy() *TLSConfig {
new.TLSCipherSuites = t.TLSCipherSuites
new.TLSMinVersion = t.TLSMinVersion
new.TLSPreferServerCipherSuites = t.TLSPreferServerCipherSuites
new.SetChecksum()
return new
@ -211,6 +219,9 @@ func (t *TLSConfig) Merge(b *TLSConfig) *TLSConfig {
if b.TLSMinVersion != "" {
result.TLSMinVersion = b.TLSMinVersion
}
if b.TLSPreferServerCipherSuites {
result.TLSPreferServerCipherSuites = true
}
return result
}

View File

@ -15,14 +15,15 @@ func TestTLSConfig_Merge(t *testing.T) {
}
b := &TLSConfig{
EnableHTTP: true,
EnableRPC: true,
VerifyServerHostname: true,
CAFile: "test-ca-file-2",
CertFile: "test-cert-file-2",
RPCUpgradeMode: true,
TLSCipherSuites: "TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256",
TLSMinVersion: "tls12",
EnableHTTP: true,
EnableRPC: true,
VerifyServerHostname: true,
CAFile: "test-ca-file-2",
CertFile: "test-cert-file-2",
RPCUpgradeMode: true,
TLSCipherSuites: "TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256",
TLSMinVersion: "tls12",
TLSPreferServerCipherSuites: true,
}
new := a.Merge(b)
@ -173,10 +174,12 @@ func TestTLS_Copy(t *testing.T) {
fookey = "../../../helper/tlsutil/testdata/nomad-foo-key.pem"
)
a := &TLSConfig{
CAFile: cafile,
CertFile: foocert,
KeyFile: fookey,
TLSCipherSuites: "TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305",
CAFile: cafile,
CertFile: foocert,
KeyFile: fookey,
TLSCipherSuites: "TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305",
TLSMinVersion: "tls12",
TLSPreferServerCipherSuites: true,
}
a.SetChecksum()

View File

@ -47,11 +47,12 @@ export default Watchable.extend({
},
xhrKey(url, method, options = {}) {
const plainKey = this._super(...arguments);
const namespace = options.data && options.data.namespace;
if (namespace) {
return `${url}?namespace=${namespace}`;
return `${plainKey}?namespace=${namespace}`;
}
return url;
return plainKey;
},
relationshipFallbackLinks: {

View File

@ -51,8 +51,8 @@ export default ApplicationAdapter.extend({
return ajaxOptions;
},
xhrKey(url /* method, options */) {
return url;
xhrKey(url, method /* options */) {
return `${method} ${url}`;
},
findAll(store, type, sinceToken, snapshotRecordArray, additionalParams = {}) {
@ -149,7 +149,7 @@ export default ApplicationAdapter.extend({
return;
}
const url = this.urlForFindRecord(id, modelName);
this.get('xhrs').cancel(url);
this.get('xhrs').cancel(`GET ${url}`);
},
cancelFindAll(modelName) {
@ -161,7 +161,7 @@ export default ApplicationAdapter.extend({
if (params) {
url = `${url}?${params}`;
}
this.get('xhrs').cancel(url);
this.get('xhrs').cancel(`GET ${url}`);
},
cancelReloadRelationship(model, relationshipName) {
@ -175,7 +175,7 @@ export default ApplicationAdapter.extend({
);
} else {
const url = model[relationship.kind](relationship.key).link();
this.get('xhrs').cancel(url);
this.get('xhrs').cancel(`GET ${url}`);
}
},
});

View File

@ -0,0 +1,30 @@
import Component from '@ember/component';
import { computed, get } from '@ember/object';
export default Component.extend({
classNames: ['accordion'],
key: 'id',
source: computed(() => []),
decoratedSource: computed('source.[]', function() {
const stateCache = this.get('stateCache');
const key = this.get('key');
const deepKey = `item.${key}`;
const decoratedSource = this.get('source').map(item => {
const cacheItem = stateCache.findBy(deepKey, get(item, key));
return {
item,
isOpen: cacheItem ? !!cacheItem.isOpen : false,
};
});
this.set('stateCache', decoratedSource);
return decoratedSource;
}),
// When source updates come in, the state cache is used to preserve
// open/close state.
stateCache: computed(() => []),
});

View File

@ -0,0 +1,6 @@
import Component from '@ember/component';
export default Component.extend({
tagName: '',
isOpen: false,
});

View File

@ -0,0 +1,16 @@
import Component from '@ember/component';
export default Component.extend({
classNames: ['accordion-head'],
classNameBindings: ['isOpen::is-light', 'isExpandable::is-inactive'],
'data-test-accordion-head': true,
buttonLabel: 'toggle',
isOpen: false,
isExpandable: true,
item: null,
onClose() {},
onOpen() {},
});

View File

@ -24,6 +24,16 @@ export default Controller.extend(Sortable, Searchable, {
listToSearch: alias('listSorted'),
sortedAllocations: alias('listSearched'),
sortedEvents: computed('model.events.@each.time', function() {
return this.get('model.events')
.sortBy('time')
.reverse();
}),
sortedDrivers: computed('model.drivers.@each.name', function() {
return this.get('model.drivers').sortBy('name');
}),
actions: {
gotoAllocation(allocation) {
this.transitionToRoute('allocations.allocation', allocation);

View File

@ -1,3 +1,4 @@
import Ember from 'ember';
import Mixin from '@ember/object/mixin';
import { assert } from '@ember/debug';
@ -7,11 +8,15 @@ export default Mixin.create({
},
setupDocumentVisibility: function() {
this.set('_visibilityHandler', this.get('visibilityHandler').bind(this));
document.addEventListener('visibilitychange', this.get('_visibilityHandler'));
if (!Ember.testing) {
this.set('_visibilityHandler', this.get('visibilityHandler').bind(this));
document.addEventListener('visibilitychange', this.get('_visibilityHandler'));
}
}.on('init'),
removeDocumentVisibility: function() {
document.removeEventListener('visibilitychange', this.get('_visibilityHandler'));
if (!Ember.testing) {
document.removeEventListener('visibilitychange', this.get('_visibilityHandler'));
}
}.on('willDestroy'),
});

View File

@ -1,3 +1,4 @@
import Ember from 'ember';
import Mixin from '@ember/object/mixin';
import { assert } from '@ember/debug';
@ -7,11 +8,15 @@ export default Mixin.create({
},
setupDocumentVisibility: function() {
this.set('_visibilityHandler', this.get('visibilityHandler').bind(this));
document.addEventListener('visibilitychange', this.get('_visibilityHandler'));
if (!Ember.testing) {
this.set('_visibilityHandler', this.get('visibilityHandler').bind(this));
document.addEventListener('visibilitychange', this.get('_visibilityHandler'));
}
}.on('activate'),
removeDocumentVisibility: function() {
document.removeEventListener('visibilitychange', this.get('_visibilityHandler'));
if (!Ember.testing) {
document.removeEventListener('visibilitychange', this.get('_visibilityHandler'));
}
}.on('deactivate'),
});

View File

@ -4,6 +4,7 @@ import Model from 'ember-data/model';
import attr from 'ember-data/attr';
import { belongsTo } from 'ember-data/relationships';
import { fragment, fragmentArray } from 'ember-data-model-fragments/attributes';
import intersection from 'npm:lodash.intersection';
import shortUUIDProperty from '../utils/properties/short-uuid';
import AllocationStats from '../utils/classes/allocation-stats';
@ -61,6 +62,17 @@ export default Model.extend({
return taskGroups && taskGroups.findBy('name', this.get('taskGroupName'));
}),
unhealthyDrivers: computed('taskGroup.drivers.[]', 'node.unhealthyDriverNames.[]', function() {
const taskGroupUnhealthyDrivers = this.get('taskGroup.drivers');
const nodeUnhealthyDrivers = this.get('node.unhealthyDriverNames');
if (taskGroupUnhealthyDrivers && nodeUnhealthyDrivers) {
return intersection(taskGroupUnhealthyDrivers, nodeUnhealthyDrivers);
}
return [];
}),
fetchStats() {
return this.get('token')
.authorizedRequest(`/v1/client/allocation/${this.get('id')}/stats`)

View File

@ -116,6 +116,28 @@ export default Model.extend({
evaluations: hasMany('evaluations'),
namespace: belongsTo('namespace'),
drivers: computed('taskGroups.@each.drivers', function() {
return this.get('taskGroups')
.mapBy('drivers')
.reduce((all, drivers) => {
all.push(...drivers);
return all;
}, [])
.uniq();
}),
// Getting all unhealthy drivers for a job can be incredibly expensive if the job
// has many allocations. This can lead to making an API request for many nodes.
unhealthyDrivers: computed('allocations.@each.unhealthyDrivers.[]', function() {
return this.get('allocations')
.mapBy('unhealthyDrivers')
.reduce((all, drivers) => {
all.push(...drivers);
return all;
}, [])
.uniq();
}),
hasBlockedEvaluation: computed('evaluations.@each.isBlocked', function() {
return this.get('evaluations')
.toArray()

View File

@ -0,0 +1,26 @@
import Fragment from 'ember-data-model-fragments/fragment';
import { computed, get } from '@ember/object';
import attr from 'ember-data/attr';
import { fragmentOwner } from 'ember-data-model-fragments/attributes';
import { fragment } from 'ember-data-model-fragments/attributes';
export default Fragment.extend({
node: fragmentOwner(),
attributes: fragment('node-attributes'),
attributesShort: computed('name', 'attributes.attributesStructured', function() {
const attributes = this.get('attributes.attributesStructured');
return get(attributes, `driver.${this.get('name')}`);
}),
name: attr('string'),
detected: attr('boolean', { defaultValue: false }),
healthy: attr('boolean', { defaultValue: false }),
healthDescription: attr('string'),
updateTime: attr('date'),
healthClass: computed('healthy', function() {
return this.get('healthy') ? 'running' : 'failed';
}),
});

View File

@ -0,0 +1,15 @@
import { alias } from '@ember/object/computed';
import Fragment from 'ember-data-model-fragments/fragment';
import attr from 'ember-data/attr';
import { fragmentOwner } from 'ember-data-model-fragments/attributes';
export default Fragment.extend({
node: fragmentOwner(),
message: attr('string'),
subsystem: attr('string'),
details: attr(),
time: attr('date'),
driver: alias('details.driver'),
});

View File

@ -2,7 +2,7 @@ import { computed } from '@ember/object';
import Model from 'ember-data/model';
import attr from 'ember-data/attr';
import { hasMany } from 'ember-data/relationships';
import { fragment } from 'ember-data-model-fragments/attributes';
import { fragment, fragmentArray } from 'ember-data-model-fragments/attributes';
import shortUUIDProperty from '../utils/properties/short-uuid';
import ipParts from '../utils/ip-parts';
@ -37,4 +37,19 @@ export default Model.extend({
}),
allocations: hasMany('allocations', { inverse: 'node' }),
drivers: fragmentArray('node-driver'),
events: fragmentArray('node-event'),
detectedDrivers: computed('drivers.@each.detected', function() {
return this.get('drivers').filterBy('detected');
}),
unhealthyDrivers: computed('detectedDrivers.@each.healthy', function() {
return this.get('detectedDrivers').filterBy('healthy', false);
}),
unhealthyDriverNames: computed('unhealthyDrivers.@each.name', function() {
return this.get('unhealthyDrivers').mapBy('name');
}),
});

View File

@ -14,6 +14,12 @@ export default Fragment.extend({
tasks: fragmentArray('task'),
drivers: computed('tasks.@each.driver', function() {
return this.get('tasks')
.mapBy('driver')
.uniq();
}),
allocations: computed('job.allocations.@each.taskGroup', function() {
return maybe(this.get('job.allocations')).filterBy('taskGroupName', this.get('name'));
}),

View File

@ -1,5 +1,6 @@
import { none } from '@ember/object/computed';
import { computed } from '@ember/object';
import { alias } from '@ember/object/computed';
import Fragment from 'ember-data-model-fragments/fragment';
import attr from 'ember-data/attr';
import { fragment, fragmentOwner, fragmentArray } from 'ember-data-model-fragments/attributes';
@ -19,6 +20,15 @@ export default Fragment.extend({
return tasks && tasks.findBy('name', this.get('name'));
}),
driver: alias('task.driver'),
// TaskState represents a task running on a node, so in addition to knowing the
// driver via the task, the health of the driver is also known via the node
driverStatus: computed('task.driver', 'allocation.node.drivers.[]', function() {
const nodeDrivers = this.get('allocation.node.drivers') || [];
return nodeDrivers.findBy('name', this.get('task.driver'));
}),
resources: fragment('resources'),
events: fragmentArray('task-event'),

View File

@ -0,0 +1,7 @@
import ApplicationSerializer from './application';
export default ApplicationSerializer.extend({
attrs: {
time: 'Timestamp',
},
});

View File

@ -1,3 +1,5 @@
import { get } from '@ember/object';
import { assign } from '@ember/polyfills';
import { inject as service } from '@ember/service';
import ApplicationSerializer from './application';
@ -9,11 +11,10 @@ export default ApplicationSerializer.extend({
},
normalize(modelClass, hash) {
// Proxy local agent to the same proxy express server Ember is using
// to avoid CORS
if (this.get('config.isDev') && hash.HTTPAddr === '127.0.0.1:4646') {
hash.HTTPAddr = '127.0.0.1:4200';
}
// Transform the map-based Drivers object into an array-based NodeDriver fragment list
hash.Drivers = Object.keys(get(hash, 'Drivers') || {}).map(key => {
return assign({}, get(hash, `Drivers.${key}`), { Name: key });
});
return this._super(modelClass, hash);
},

View File

@ -1,3 +1,4 @@
@import './components/accordion';
@import './components/badge';
@import './components/boxed-section';
@import './components/cli-window';

View File

@ -0,0 +1,42 @@
.accordion {
.accordion-head,
.accordion-body {
border: 1px solid $grey-blue;
border-bottom: none;
padding: 0.75em 1.5em;
&:first-child {
border-top-left-radius: $radius;
border-top-right-radius: $radius;
}
&:last-child {
border-bottom: 1px solid $grey-blue;
border-bottom-left-radius: $radius;
border-bottom-right-radius: $radius;
}
}
.accordion-head {
display: flex;
background: $white-ter;
flex: 1;
&.is-light {
background: $white;
}
&.is-inactive {
color: $grey-light;
}
.accordion-head-content {
width: 100%;
}
.accordion-toggle {
flex-basis: 0;
white-space: nowrap;
}
}
}

View File

@ -33,4 +33,13 @@
&.is-faded {
color: rgba($text, 0.8);
}
&.is-small {
padding: 0.15em 0.5em;
}
&.is-secondary {
color: darken($grey-blue, 30%);
background: lighten($grey-blue, 10%);
}
}

View File

@ -86,6 +86,11 @@
&.is-narrow {
padding: 1.25em 0 1.25em 0.5em;
& + th,
& + td {
padding-left: 0.5em;
}
}
// Only use px modifiers when text needs to be truncated.

View File

@ -40,6 +40,7 @@
sortDescending=sortDescending
class="is-striped" as |t|}}
{{#t.head}}
<th class="is-narrow"></th>
{{#t.sort-by prop="name"}}Name{{/t.sort-by}}
{{#t.sort-by prop="state"}}State{{/t.sort-by}}
<th>Last Event</th>
@ -48,6 +49,13 @@
{{/t.head}}
{{#t.body as |row|}}
<tr data-test-task-row={{row.model.task.name}}>
<td class="is-narrow">
{{#if (not row.model.driverStatus.healthy)}}
<span data-test-icon="unhealthy-driver" class="tooltip text-center" aria-label="{{row.model.driver}} is unhealthy">
{{x-icon "warning" class="is-warning"}}
</span>
{{/if}}
</td>
<td data-test-name>
{{#link-to "allocations.allocation.task" row.model.allocation row.model}}
{{row.model.name}}

View File

@ -17,9 +17,27 @@
<div class="boxed-section is-small">
<div class="boxed-section-body inline-definitions">
<span class="label">Client Details</span>
<span class="pair" data-test-status-definition><span class="term">Status</span> <span class="status-text node-{{model.status}}">{{model.status}}</span></span>
<span class="pair" data-test-address-definition><span class="term">Address</span> {{model.httpAddr}}</span>
<span class="pair" data-test-datacenter-definition><span class="term">Datacenter</span> {{model.datacenter}}</span>
<span class="pair" data-test-status-definition>
<span class="term">Status</span>
<span class="status-text node-{{model.status}}">{{model.status}}</span>
</span>
<span class="pair" data-test-address-definition>
<span class="term">Address</span>
{{model.httpAddr}}
</span>
<span class="pair" data-test-datacenter-definition>
<span class="term">Datacenter</span>
{{model.datacenter}}
</span>
<span class="pair" data-test-driver-health>
<span class="term">Drivers</span>
{{#if model.unhealthyDrivers.length}}
{{x-icon "warning" class="is-text is-warning"}}
{{model.unhealthyDrivers.length}} of {{model.detectedDrivers.length}} {{pluralize "driver" model.detectedDrivers.length}} unhealthy
{{else}}
All healthy
{{/if}}
</span>
</div>
</div>
@ -75,6 +93,95 @@
</div>
</div>
<div data-test-client-events class="boxed-section">
<div class="boxed-section-head">
Client Events
</div>
<div class="boxed-section-body is-full-bleed">
{{#list-table source=sortedEvents class="is-striped" as |t|}}
{{#t.head}}
<th class="is-2">Time</th>
<th class="is-2">Subsystem</th>
<th>Message</th>
{{/t.head}}
{{#t.body as |row|}}
<tr data-test-client-event>
<td data-test-client-event-time>{{moment-format row.model.time "MM/DD/YY HH:mm:ss"}}</td>
<td data-test-client-event-subsystem>{{row.model.subsystem}}</td>
<td data-test-client-event-message>
{{#if row.model.message}}
{{#if row.model.driver}}
<span class="badge is-secondary is-small">{{row.model.driver}}</span>
{{/if}}
{{row.model.message}}
{{else}}
<em>No message</em>
{{/if}}
</td>
</tr>
{{/t.body}}
{{/list-table}}
</div>
</div>
<div data-test-driver-status class="boxed-section">
<div class="boxed-section-head">
Driver Status
</div>
<div class="boxed-section-body">
{{#list-accordion source=sortedDrivers key="name" as |a|}}
{{#a.head buttonLabel="details" isExpandable=a.item.detected}}
<div class="columns inline-definitions {{unless a.item.detected "is-faded"}}">
<div class="column is-1">
<span data-test-name>{{a.item.name}}</span>
</div>
<div class="column is-2">
{{#if a.item.detected}}
<span data-test-health>
<span class="color-swatch {{a.item.healthClass}}"></span>
{{if a.item.healthy "Healthy" "Unhealthy"}}
</span>
{{/if}}
</div>
<div class="column">
<span class="pair">
<span class="term">Detected</span>
<span data-test-detected>{{if a.item.detected "Yes" "No"}}</span>
</span>
<span class="is-pulled-right">
<span class="pair">
<span class="term">Last Updated</span>
<span data-test-last-updated>{{moment-from-now a.item.updateTime interval=1000}}</span>
</span>
</span>
</div>
</div>
{{/a.head}}
{{#a.body}}
<p data-test-health-description class="message">{{a.item.healthDescription}}</p>
<div data-test-driver-attributes class="boxed-section">
<div class="boxed-section-head">
{{capitalize a.item.name}} Attributes
</div>
{{#if a.item.attributes.attributesStructured}}
<div class="boxed-section-body is-full-bleed">
{{attributes-table
attributes=a.item.attributesShort
class="attributes-table"}}
</div>
{{else}}
<div class="boxed-section-body">
<div class="empty-message">
<h3 class="empty-message-headline">No Driver Attributes</h3>
</div>
</div>
{{/if}}
</div>
{{/a.body}}
{{/list-accordion}}
</div>
</div>
<div class="boxed-section">
<div class="boxed-section-head">
Attributes

View File

@ -23,6 +23,7 @@
sortDescending=sortDescending
class="with-foot" as |t|}}
{{#t.head}}
<th class="is-narrow"></th>
{{#t.sort-by prop="id"}}ID{{/t.sort-by}}
{{#t.sort-by class="is-200px is-truncatable" prop="name"}}Name{{/t.sort-by}}
{{#t.sort-by prop="status"}}Status{{/t.sort-by}}

View File

@ -1,6 +1,11 @@
<td data-test-indicators class="is-narrow">
{{#if allocation.unhealthyDrivers.length}}
<span data-test-icon="unhealthy-driver" class="tooltip text-center" aria-label="Allocation depends on unhealthy drivers">
{{x-icon "warning" class="is-warning"}}
</span>
{{/if}}
{{#if allocation.nextAllocation}}
<span class="tooltip text-center" aria-label="Allocation was rescheduled">
<span data-test-icon="reschedule" class="tooltip text-center" aria-label="Allocation was rescheduled">
{{x-icon "history" class="is-faded"}}
</span>
{{/if}}

View File

@ -1,3 +1,10 @@
<td data-test-icon class="is-narrow">
{{#if node.unhealthyDrivers.length}}
<span class="tooltip text-center" aria-label="Client has unhealthy drivers">
{{x-icon "warning" class="is-warning"}}
</span>
{{/if}}
</td>
<td data-test-client-id>{{#link-to "clients.client" node.id class="is-primary"}}{{node.shortId}}{{/link-to}}</td>
<td data-test-client-name class="is-200px is-truncatable" title="{{node.name}}">{{node.name}}</td>
<td data-test-client-status>{{node.status}}</td>

View File

@ -0,0 +1,10 @@
{{#each decoratedSource as |item|}}
{{yield (hash
head=(component "list-accordion/accordion-head"
isOpen=item.isOpen
onOpen=(action (mut item.isOpen) true)
onClose=(action (mut item.isOpen) false))
body=(component "list-accordion/accordion-body" isOpen=item.isOpen)
item=item.item
)}}
{{/each}}

View File

@ -0,0 +1,5 @@
{{#if isOpen}}
<div data-test-accordion-body class="accordion-body">
{{yield}}
</div>
{{/if}}

View File

@ -0,0 +1,9 @@
<div class="accordion-head-content">
{{yield}}
</div>
<button
data-test-accordion-toggle
class="button is-light is-compact pull-right accordion-toggle {{unless isExpandable "is-invisible"}}"
onclick={{action (if isOpen onClose onOpen) item}}>
{{buttonLabel}}
</button>

View File

@ -1,5 +1,5 @@
{{#if isIdle}}
<button data-test-idle-button type="button" class="button is-warning is-small is-inline" onclick={{action "promptForConfirmation"}}>
<button data-test-idle-button type="button" class="button is-danger is-outlined is-important is-small is-inline" onclick={{action "promptForConfirmation"}}>
{{idleText}}
</button>
{{else if isPendingConfirmation}}

View File

@ -0,0 +1,12 @@
import { Factory, faker } from 'ember-cli-mirage';
import { provide } from '../utils';
const REF_TIME = new Date();
const STATES = provide(10, faker.system.fileExt.bind(faker.system));
export default Factory.extend({
subsystem: faker.list.random(...STATES),
message: () => faker.lorem.sentence(),
time: () => faker.date.past(2 / 365, REF_TIME),
details: null,
});

View File

@ -4,6 +4,7 @@ import { DATACENTERS, HOSTS } from '../common';
const UUIDS = provide(100, faker.random.uuid.bind(faker.random));
const NODE_STATUSES = ['initializing', 'ready', 'down'];
const REF_DATE = new Date();
export default Factory.extend({
id: i => (i / 100 >= 1 ? `${UUIDS[i]}-${i}` : UUIDS[i]),
@ -28,6 +29,8 @@ export default Factory.extend({
},
}),
drivers: makeDrivers,
attributes() {
// TODO add variability to these
return {
@ -72,5 +75,42 @@ export default Factory.extend({
server.create('client-stats', {
id: node.httpAddr,
});
const events = server.createList('node-event', faker.random.number({ min: 1, max: 10 }), {
nodeId: node.id,
});
node.update({
eventIds: events.mapBy('id'),
});
},
});
function makeDrivers() {
const generate = name => {
const detected = Math.random() > 0.3;
const healthy = detected && Math.random() > 0.3;
const attributes = {
[`driver.${name}.version`]: '1.0.0',
[`driver.${name}.status`]: 'awesome',
[`driver.${name}.more.details`]: 'yeah',
[`driver.${name}.more.again`]: 'we got that',
};
return {
Detected: detected,
Healthy: healthy,
HealthDescription: healthy ? 'Driver is healthy' : 'Uh oh',
UpdateTime: faker.date.past(5 / 365, REF_DATE),
Attributes: Math.random() > 0.3 && detected ? attributes : null,
};
};
return {
docker: generate('docker'),
rkt: generate('rkt'),
qemu: generate('qemu'),
exec: generate('exec'),
raw_exec: generate('raw_exec'),
java: generate('java'),
};
}

View File

@ -1,4 +1,4 @@
import { Factory, faker, trait } from 'ember-cli-mirage';
import { Factory, faker } from 'ember-cli-mirage';
import { provide } from '../utils';
const REF_TIME = new Date();

View File

@ -1,6 +1,8 @@
import { Factory, faker } from 'ember-cli-mirage';
import { generateResources } from '../common';
const DRIVERS = ['docker', 'java', 'rkt', 'qemu', 'exec', 'raw_exec'];
export default Factory.extend({
// Hidden property used to compute the Summary hash
groupNames: [],
@ -8,6 +10,7 @@ export default Factory.extend({
JobID: '',
name: id => `task-${faker.hacker.noun()}-${id}`,
driver: faker.list.random(...DRIVERS),
Resources: generateResources,
});

5
ui/mirage/models/node.js Normal file
View File

@ -0,0 +1,5 @@
import { Model, hasMany } from 'ember-cli-mirage';
export default Model.extend({
events: hasMany('node-event'),
});

View File

@ -0,0 +1,6 @@
import ApplicationSerializer from './application';
export default ApplicationSerializer.extend({
embed: true,
include: ['events'],
});

View File

@ -72,6 +72,7 @@
"json-formatter-js": "^2.2.0",
"lint-staged": "^6.0.0",
"loader.js": "^4.2.3",
"lodash.intersection": "^4.4.0",
"prettier": "^1.4.4",
"query-string": "^5.0.0"
},

View File

@ -1,4 +1,5 @@
import $ from 'jquery';
import { assign } from '@ember/polyfills';
import { click, findAll, currentURL, find, visit, waitFor } from 'ember-native-dom-helpers';
import { test } from 'qunit';
import moduleForAcceptance from 'nomad-ui/tests/helpers/module-for-acceptance';
@ -13,9 +14,24 @@ moduleForAcceptance('Acceptance | allocation detail', {
server.create('agent');
node = server.create('node');
job = server.create('job', { groupCount: 0 });
job = server.create('job', { groupCount: 0, createAllocations: false });
allocation = server.create('allocation', 'withTaskWithPorts');
// Make sure the node has an unhealthy driver
node.update({
driver: assign(node.drivers, {
docker: {
detected: true,
healthy: false,
},
}),
});
// Make sure a task for the allocation depends on the unhealthy driver
server.schema.tasks.first().update({
driver: 'docker',
});
visit(`/allocations/${allocation.id}`);
},
});
@ -121,6 +137,10 @@ test('each task row should list high-level information for the task', function(a
});
});
test('tasks with an unhealthy driver have a warning icon', function(assert) {
assert.ok(find('[data-test-task-row] [data-test-icon="unhealthy-driver"]'), 'Warning is shown');
});
test('when the allocation has not been rescheduled, the reschedule events section is not rendered', function(assert) {
assert.notOk(find('[data-test-reschedule-events]'), 'Reschedule Events section exists');
});

View File

@ -1,3 +1,4 @@
import { assign } from '@ember/polyfills';
import $ from 'jquery';
import { click, find, findAll, currentURL, visit } from 'ember-native-dom-helpers';
import { test } from 'qunit';
@ -24,12 +25,12 @@ test('/clients/:id should have a breadcrumb trail linking back to clients', func
andThen(() => {
assert.equal(
find('[data-test-breadcrumb="clients"]').textContent,
find('[data-test-breadcrumb="clients"]').textContent.trim(),
'Clients',
'First breadcrumb says clients'
);
assert.equal(
find('[data-test-breadcrumb="client"]').textContent,
find('[data-test-breadcrumb="client"]').textContent.trim(),
node.id.split('-')[0],
'Second breadcrumb says the node short id'
);
@ -58,23 +59,26 @@ test('/clients/:id should list additional detail for the node below the title',
visit(`/clients/${node.id}`);
andThen(() => {
assert.equal(
findAll('.inline-definitions .pair')[0].textContent,
`Status ${node.status}`,
assert.ok(
find('.inline-definitions .pair')
.textContent.trim()
.includes(node.status),
'Status is in additional details'
);
assert.ok(
$('[data-test-status-definition] .status-text').hasClass(`node-${node.status}`),
'Status is decorated with a status class'
);
assert.equal(
find('[data-test-address-definition]').textContent,
`Address ${node.httpAddr}`,
assert.ok(
find('[data-test-address-definition]')
.textContent.trim()
.includes(node.httpAddr),
'Address is in additional details'
);
assert.equal(
find('[data-test-datacenter-definition]').textContent,
`Datacenter ${node.datacenter}`,
assert.ok(
find('[data-test-datacenter-definition]')
.textContent.trim()
.includes(node.datacenter),
'Datacenter is in additional details'
);
});
@ -330,9 +334,174 @@ test('when the node is not found, an error message is shown, but the URL persist
assert.equal(currentURL(), '/clients/not-a-real-node', 'The URL persists');
assert.ok(find('[data-test-error]'), 'Error message is shown');
assert.equal(
find('[data-test-error-title]').textContent,
find('[data-test-error-title]').textContent.trim(),
'Not Found',
'Error message is for 404'
);
});
});
test('/clients/:id shows the recent events list', function(assert) {
visit(`/clients/${node.id}`);
andThen(() => {
assert.ok(find('[data-test-client-events]'), 'Client events section exists');
});
});
test('each node event shows basic node event information', function(assert) {
const event = server.db.nodeEvents
.where({ nodeId: node.id })
.sortBy('time')
.reverse()[0];
visit(`/clients/${node.id}`);
andThen(() => {
const eventRow = $(find('[data-test-client-event]'));
assert.equal(
eventRow
.find('[data-test-client-event-time]')
.text()
.trim(),
moment(event.time).format('MM/DD/YY HH:mm:ss'),
'Event timestamp'
);
assert.equal(
eventRow
.find('[data-test-client-event-subsystem]')
.text()
.trim(),
event.subsystem,
'Event subsystem'
);
assert.equal(
eventRow
.find('[data-test-client-event-message]')
.text()
.trim(),
event.message,
'Event message'
);
});
});
test('/clients/:id shows the driver status of every driver for the node', function(assert) {
// Set the drivers up so health and detection is well tested
const nodeDrivers = node.drivers;
const undetectedDriver = 'raw_exec';
Object.values(nodeDrivers).forEach(driver => {
driver.Detected = true;
});
nodeDrivers[undetectedDriver].Detected = false;
node.drivers = nodeDrivers;
const drivers = Object.keys(node.drivers)
.map(driverName => assign({ Name: driverName }, node.drivers[driverName]))
.sortBy('Name');
assert.ok(drivers.length > 0, 'Node has drivers');
visit(`/clients/${node.id}`);
andThen(() => {
const driverRows = findAll('[data-test-driver-status] [data-test-accordion-head]');
drivers.forEach((driver, index) => {
const driverRow = $(driverRows[index]);
assert.equal(
driverRow
.find('[data-test-name]')
.text()
.trim(),
driver.Name,
`${driver.Name}: Name is correct`
);
assert.equal(
driverRow
.find('[data-test-detected]')
.text()
.trim(),
driver.Detected ? 'Yes' : 'No',
`${driver.Name}: Detection is correct`
);
assert.equal(
driverRow
.find('[data-test-last-updated]')
.text()
.trim(),
moment(driver.UpdateTime).fromNow(),
`${driver.Name}: Last updated shows time since now`
);
if (driver.Name === undetectedDriver) {
assert.notOk(
driverRow.find('[data-test-health]').length,
`${driver.Name}: No health for the undetected driver`
);
} else {
assert.equal(
driverRow
.find('[data-test-health]')
.text()
.trim(),
driver.Healthy ? 'Healthy' : 'Unhealthy',
`${driver.Name}: Health is correct`
);
assert.ok(
driverRow
.find('[data-test-health] .color-swatch')
.hasClass(driver.Healthy ? 'running' : 'failed'),
`${driver.Name}: Swatch with correct class is shown`
);
}
});
});
});
test('each driver can be opened to see a message and attributes', function(assert) {
// Only detected drivers can be expanded
const nodeDrivers = node.drivers;
Object.values(nodeDrivers).forEach(driver => {
driver.Detected = true;
});
node.drivers = nodeDrivers;
const driver = Object.keys(node.drivers)
.map(driverName => assign({ Name: driverName }, node.drivers[driverName]))
.sortBy('Name')[0];
visit(`/clients/${node.id}`);
andThen(() => {
const driverBody = $(find('[data-test-driver-status] [data-test-accordion-body]'));
assert.notOk(
driverBody.find('[data-test-health-description]').length,
'Driver health description is not shown'
);
assert.notOk(
driverBody.find('[data-test-driver-attributes]').length,
'Driver attributes section is not shown'
);
click('[data-test-driver-status] [data-test-accordion-toggle]');
});
andThen(() => {
const driverBody = $(find('[data-test-driver-status] [data-test-accordion-body]'));
assert.equal(
driverBody
.find('[data-test-health-description]')
.text()
.trim(),
driver.HealthDescription,
'Driver health description is now shown'
);
assert.ok(
driverBody.find('[data-test-driver-attributes]').length,
'Driver attributes section is now shown'
);
});
});

View File

@ -237,8 +237,11 @@ test('when the allocation has reschedule events, the allocation row is denoted w
const normalRow = find(`[data-test-allocation="${allocations[1].id}"]`);
assert.ok(
rescheduleRow.querySelector('[data-test-indicators] .icon'),
'Reschedule row has an icon'
rescheduleRow.querySelector('[data-test-indicators] [data-test-icon="reschedule"]'),
'Reschedule row has a reschedule icon'
);
assert.notOk(
normalRow.querySelector('[data-test-indicators] [data-test-icon="reschedule"]'),
'Normal row has no reschedule icon'
);
assert.notOk(normalRow.querySelector('[data-test-indicators] .icon'), 'Normal row has no icon');
});

View File

@ -4,11 +4,14 @@ import wait from 'ember-test-helpers/wait';
import hbs from 'htmlbars-inline-precompile';
import generateResources from '../../mirage/data/generate-resources';
import { startMirage } from 'nomad-ui/initializers/ember-cli-mirage';
import { find } from 'ember-native-dom-helpers';
import Response from 'ember-cli-mirage/response';
import { initialize as fragmentSerializerInitializer } from 'nomad-ui/initializers/fragment-serializer';
moduleForComponent('allocation-row', 'Integration | Component | allocation row', {
integration: true,
beforeEach() {
fragmentSerializerInitializer(getOwner(this));
this.store = getOwner(this).lookup('service:store');
this.server = startMirage();
this.server.create('namespace');
@ -83,3 +86,40 @@ test('Allocation row polls for stats, even when it errors or has an invalid resp
);
});
});
test('Allocation row shows warning when it requires drivers that are unhealthy on the node it is running on', function(assert) {
const node = this.server.schema.nodes.first();
const drivers = node.drivers;
Object.values(drivers).forEach(driver => {
driver.Healthy = false;
driver.Detected = true;
});
node.update({ drivers });
this.server.create('allocation');
this.store.findAll('job');
this.store.findAll('node');
this.store.findAll('allocation');
let allocation;
return wait()
.then(() => {
allocation = this.store.peekAll('allocation').get('firstObject');
this.setProperties({
allocation,
context: 'job',
});
this.render(hbs`
{{allocation-row
allocation=allocation
context=context}}
`);
return wait();
})
.then(() => {
assert.ok(find('[data-test-icon="unhealthy-driver"]'), 'Unhealthy driver icon is shown');
});
});

View File

@ -1,3 +1,4 @@
import EmberObject from '@ember/object';
import { run } from '@ember/runloop';
import { assign } from '@ember/polyfills';
import { test } from 'ember-qunit';
@ -10,8 +11,12 @@ moduleForAdapter('job', 'Unit | Adapter | Job', {
'adapter:job',
'service:token',
'service:system',
'model:namespace',
'model:allocation',
'model:deployment',
'model:evaluation',
'model:job-summary',
'model:job-version',
'model:namespace',
'adapter:application',
'service:watchList',
],
@ -292,6 +297,36 @@ test('requests can be canceled even if multiple requests for the same URL were m
});
});
test('canceling a find record request will never cancel a request with the same url but different method', function(assert) {
const { pretender } = this.server;
const jobId = JSON.stringify(['job-1', 'default']);
pretender.get('/v1/job/:id', () => [200, {}, '{}'], true);
pretender.delete('/v1/job/:id', () => [204, {}, ''], 200);
this.subject().findRecord(null, { modelName: 'job' }, jobId, {
reload: true,
adapterOptions: { watch: true },
});
this.subject().stop(EmberObject.create({ id: jobId }));
const { request: getXHR } = pretender.requestReferences[0];
const { request: deleteXHR } = pretender.requestReferences[1];
assert.equal(getXHR.status, 0, 'Get request is still pending');
assert.equal(deleteXHR.status, 0, 'Delete request is still pending');
// Schedule the cancelation before waiting
run.next(() => {
this.subject().cancelFindRecord('job', jobId);
});
return wait().then(() => {
assert.ok(getXHR.aborted, 'Get request was aborted');
assert.notOk(deleteXHR.aborted, 'Delete request was aborted');
});
});
function makeMockModel(id, options) {
return assign(
{

View File

@ -9,6 +9,9 @@ moduleForAdapter('node', 'Unit | Adapter | Node', {
'adapter:node',
'model:node-attributes',
'model:allocation',
'model:node-driver',
'model:node-event',
'model:evaluation',
'model:job',
'serializer:application',
'serializer:node',
@ -16,6 +19,7 @@ moduleForAdapter('node', 'Unit | Adapter | Node', {
'service:config',
'service:watchList',
'transform:fragment',
'transform:fragment-array',
],
beforeEach() {
this.server = startMirage();

View File

@ -6,7 +6,13 @@ import moduleForSerializer from '../../helpers/module-for-serializer';
import pushPayloadToStore from '../../utils/push-payload-to-store';
moduleForSerializer('node', 'Unit | Serializer | Node', {
needs: ['serializer:node', 'service:config', 'transform:fragment', 'model:allocation'],
needs: [
'serializer:node',
'service:config',
'transform:fragment',
'transform:fragment-array',
'model:allocation',
],
});
test('local store is culled to reflect the state of findAll requests', function(assert) {

View File

@ -5691,6 +5691,10 @@ lodash.identity@~2.3.0:
version "2.3.0"
resolved "https://registry.yarnpkg.com/lodash.identity/-/lodash.identity-2.3.0.tgz#6b01a210c9485355c2a913b48b6711219a173ded"
lodash.intersection@^4.4.0:
version "4.4.0"
resolved "https://registry.yarnpkg.com/lodash.intersection/-/lodash.intersection-4.4.0.tgz#0a11ba631d0e95c23c7f2f4cbb9a692ed178e705"
lodash.isarguments@^3.0.0:
version "3.1.0"
resolved "https://registry.yarnpkg.com/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz#2f573d85c6a24289ff00663b491c1d338ff3458a"

View File

@ -58,14 +58,18 @@ the [Agent's Gossip and RPC Encryption](/docs/agent/encryption.html).
cluster is being upgraded to TLS, and removed after the migration is
complete. This allows the agent to accept both TLS and plaintext traffic.
- `tls_cipher_suites` - Specifies the TLS cipher suites that will be used by
the agent. Known insecure ciphers are disabled (3DES and RC4). By default,
an agent is configured to use TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305,
- `tls_cipher_suites` `(array<string>: [])` - Specifies the TLS cipher suites
that will be used by the agent. Known insecure ciphers are disabled (3DES and
RC4). By default, an agent is configured to use
TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305,
TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256, and
TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384.
- `tls_min_version` - Specifies the minimum supported version of TLS. Accepted
values are "tls10", "tls11", "tls12". Defaults to TLS 1.2.
- `tls_min_version` `(string: "tls12")`- Specifies the minimum supported version
of TLS. Accepted values are "tls10", "tls11", "tls12".
- `tls_prefer_server_cipher_suites` `(bool: false)` - Specifies whether
TLS connections should prefer the server's ciphersuites over the client's.
- `verify_https_client` `(bool: false)` - Specifies agents should require
client certificates for all incoming HTTPS requests. The client certificates