diff --git a/CHANGELOG.md b/CHANGELOG.md index 17ba5e069..d6054829e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/client/driver/executor/executor.go b/client/driver/executor/executor.go index 6b18612f8..7ddccce69 100644 --- a/client/driver/executor/executor.go +++ b/client/driver/executor/executor.go @@ -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() +} diff --git a/command/agent/config-test-fixtures/basic.hcl b/command/agent/config-test-fixtures/basic.hcl index 0c83ae95f..7398dff43 100644 --- a/command/agent/config-test-fixtures/basic.hcl +++ b/command/agent/config-test-fixtures/basic.hcl @@ -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" { diff --git a/command/agent/config_parse.go b/command/agent/config_parse.go index b9577327a..9d82050cb 100644 --- a/command/agent/config_parse.go +++ b/command/agent/config_parse.go @@ -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 { diff --git a/command/agent/config_parse_test.go b/command/agent/config_parse_test.go index b47fa572c..994ed1d2a 100644 --- a/command/agent/config_parse_test.go +++ b/command/agent/config_parse_test.go @@ -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": "*", diff --git a/helper/tlsutil/config.go b/helper/tlsutil/config.go index cb440f579..91d2cfb66 100644 --- a/helper/tlsutil/config.go +++ b/helper/tlsutil/config.go @@ -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 diff --git a/helper/tlsutil/config_test.go b/helper/tlsutil/config_test.go index f5eb1611f..f8eca41aa 100644 --- a/helper/tlsutil/config_test.go +++ b/helper/tlsutil/config_test.go @@ -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) diff --git a/nomad/structs/config/tls.go b/nomad/structs/config/tls.go index b0ebfda49..4476c78b6 100644 --- a/nomad/structs/config/tls.go +++ b/nomad/structs/config/tls.go @@ -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 } diff --git a/nomad/structs/config/tls_test.go b/nomad/structs/config/tls_test.go index bda9942fb..b57b4fa25 100644 --- a/nomad/structs/config/tls_test.go +++ b/nomad/structs/config/tls_test.go @@ -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() diff --git a/ui/app/adapters/job.js b/ui/app/adapters/job.js index 984564572..eeba06522 100644 --- a/ui/app/adapters/job.js +++ b/ui/app/adapters/job.js @@ -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: { diff --git a/ui/app/adapters/watchable.js b/ui/app/adapters/watchable.js index 2f3257efd..9f7c072cc 100644 --- a/ui/app/adapters/watchable.js +++ b/ui/app/adapters/watchable.js @@ -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}`); } }, }); diff --git a/ui/app/components/list-accordion.js b/ui/app/components/list-accordion.js new file mode 100644 index 000000000..9e004645d --- /dev/null +++ b/ui/app/components/list-accordion.js @@ -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(() => []), +}); diff --git a/ui/app/components/list-accordion/accordion-body.js b/ui/app/components/list-accordion/accordion-body.js new file mode 100644 index 000000000..32397fce6 --- /dev/null +++ b/ui/app/components/list-accordion/accordion-body.js @@ -0,0 +1,6 @@ +import Component from '@ember/component'; + +export default Component.extend({ + tagName: '', + isOpen: false, +}); diff --git a/ui/app/components/list-accordion/accordion-head.js b/ui/app/components/list-accordion/accordion-head.js new file mode 100644 index 000000000..9cff6e92b --- /dev/null +++ b/ui/app/components/list-accordion/accordion-head.js @@ -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() {}, +}); diff --git a/ui/app/controllers/clients/client.js b/ui/app/controllers/clients/client.js index 008169f5e..26dfc9b64 100644 --- a/ui/app/controllers/clients/client.js +++ b/ui/app/controllers/clients/client.js @@ -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); diff --git a/ui/app/mixins/with-component-visibility-detection.js b/ui/app/mixins/with-component-visibility-detection.js index 66d1097ee..605937834 100644 --- a/ui/app/mixins/with-component-visibility-detection.js +++ b/ui/app/mixins/with-component-visibility-detection.js @@ -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'), }); diff --git a/ui/app/mixins/with-route-visibility-detection.js b/ui/app/mixins/with-route-visibility-detection.js index 1df6e5f45..57e8300e3 100644 --- a/ui/app/mixins/with-route-visibility-detection.js +++ b/ui/app/mixins/with-route-visibility-detection.js @@ -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'), }); diff --git a/ui/app/models/allocation.js b/ui/app/models/allocation.js index 048e31e55..f4dbad07d 100644 --- a/ui/app/models/allocation.js +++ b/ui/app/models/allocation.js @@ -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`) diff --git a/ui/app/models/job.js b/ui/app/models/job.js index b8ba9670f..621b869ca 100644 --- a/ui/app/models/job.js +++ b/ui/app/models/job.js @@ -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() diff --git a/ui/app/models/node-driver.js b/ui/app/models/node-driver.js new file mode 100644 index 000000000..371c5cc49 --- /dev/null +++ b/ui/app/models/node-driver.js @@ -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'; + }), +}); diff --git a/ui/app/models/node-event.js b/ui/app/models/node-event.js new file mode 100644 index 000000000..8fcf3cfad --- /dev/null +++ b/ui/app/models/node-event.js @@ -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'), +}); diff --git a/ui/app/models/node.js b/ui/app/models/node.js index 4b5f34a44..4dbb4fec6 100644 --- a/ui/app/models/node.js +++ b/ui/app/models/node.js @@ -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'); + }), }); diff --git a/ui/app/models/task-group.js b/ui/app/models/task-group.js index 9be65bdba..ab1a37bbe 100644 --- a/ui/app/models/task-group.js +++ b/ui/app/models/task-group.js @@ -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')); }), diff --git a/ui/app/models/task-state.js b/ui/app/models/task-state.js index 217e87459..e0c8918c8 100644 --- a/ui/app/models/task-state.js +++ b/ui/app/models/task-state.js @@ -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'), diff --git a/ui/app/serializers/node-event.js b/ui/app/serializers/node-event.js new file mode 100644 index 000000000..606c6dadd --- /dev/null +++ b/ui/app/serializers/node-event.js @@ -0,0 +1,7 @@ +import ApplicationSerializer from './application'; + +export default ApplicationSerializer.extend({ + attrs: { + time: 'Timestamp', + }, +}); diff --git a/ui/app/serializers/node.js b/ui/app/serializers/node.js index e0ecfc9a7..a8d3410e2 100644 --- a/ui/app/serializers/node.js +++ b/ui/app/serializers/node.js @@ -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); }, diff --git a/ui/app/styles/components.scss b/ui/app/styles/components.scss index 49b513806..83efbb676 100644 --- a/ui/app/styles/components.scss +++ b/ui/app/styles/components.scss @@ -1,3 +1,4 @@ +@import './components/accordion'; @import './components/badge'; @import './components/boxed-section'; @import './components/cli-window'; diff --git a/ui/app/styles/components/accordion.scss b/ui/app/styles/components/accordion.scss new file mode 100644 index 000000000..17ca8bcc5 --- /dev/null +++ b/ui/app/styles/components/accordion.scss @@ -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; + } + } +} diff --git a/ui/app/styles/components/badge.scss b/ui/app/styles/components/badge.scss index e5fc8b173..60f4f2c74 100644 --- a/ui/app/styles/components/badge.scss +++ b/ui/app/styles/components/badge.scss @@ -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%); + } } diff --git a/ui/app/styles/core/table.scss b/ui/app/styles/core/table.scss index 97ff41845..63822653b 100644 --- a/ui/app/styles/core/table.scss +++ b/ui/app/styles/core/table.scss @@ -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. diff --git a/ui/app/templates/allocations/allocation/index.hbs b/ui/app/templates/allocations/allocation/index.hbs index 1aa22ab0a..c2e0a60ce 100644 --- a/ui/app/templates/allocations/allocation/index.hbs +++ b/ui/app/templates/allocations/allocation/index.hbs @@ -40,6 +40,7 @@ sortDescending=sortDescending class="is-striped" as |t|}} {{#t.head}} + {{#t.sort-by prop="name"}}Name{{/t.sort-by}} {{#t.sort-by prop="state"}}State{{/t.sort-by}} Last Event @@ -48,6 +49,13 @@ {{/t.head}} {{#t.body as |row|}} + + {{#if (not row.model.driverStatus.healthy)}} + + {{x-icon "warning" class="is-warning"}} + + {{/if}} + {{#link-to "allocations.allocation.task" row.model.allocation row.model}} {{row.model.name}} diff --git a/ui/app/templates/clients/client.hbs b/ui/app/templates/clients/client.hbs index b9ec625aa..f5ad36c9f 100644 --- a/ui/app/templates/clients/client.hbs +++ b/ui/app/templates/clients/client.hbs @@ -17,9 +17,27 @@
Client Details - Status {{model.status}} - Address {{model.httpAddr}} - Datacenter {{model.datacenter}} + + Status + {{model.status}} + + + Address + {{model.httpAddr}} + + + Datacenter + {{model.datacenter}} + + + Drivers + {{#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}} +
@@ -75,6 +93,95 @@ +
+
+ Client Events +
+
+ {{#list-table source=sortedEvents class="is-striped" as |t|}} + {{#t.head}} + Time + Subsystem + Message + {{/t.head}} + {{#t.body as |row|}} + + {{moment-format row.model.time "MM/DD/YY HH:mm:ss"}} + {{row.model.subsystem}} + + {{#if row.model.message}} + {{#if row.model.driver}} + {{row.model.driver}} + {{/if}} + {{row.model.message}} + {{else}} + No message + {{/if}} + + + {{/t.body}} + {{/list-table}} +
+
+ +
+
+ Driver Status +
+
+ {{#list-accordion source=sortedDrivers key="name" as |a|}} + {{#a.head buttonLabel="details" isExpandable=a.item.detected}} +
+
+ {{a.item.name}} +
+
+ {{#if a.item.detected}} + + + {{if a.item.healthy "Healthy" "Unhealthy"}} + + {{/if}} +
+
+ + Detected + {{if a.item.detected "Yes" "No"}} + + + + Last Updated + {{moment-from-now a.item.updateTime interval=1000}} + + +
+
+ {{/a.head}} + {{#a.body}} +

{{a.item.healthDescription}}

+
+
+ {{capitalize a.item.name}} Attributes +
+ {{#if a.item.attributes.attributesStructured}} +
+ {{attributes-table + attributes=a.item.attributesShort + class="attributes-table"}} +
+ {{else}} +
+
+

No Driver Attributes

+
+
+ {{/if}} +
+ {{/a.body}} + {{/list-accordion}} +
+
+
Attributes diff --git a/ui/app/templates/clients/index.hbs b/ui/app/templates/clients/index.hbs index 1b43c78c3..888c05634 100644 --- a/ui/app/templates/clients/index.hbs +++ b/ui/app/templates/clients/index.hbs @@ -23,6 +23,7 @@ sortDescending=sortDescending class="with-foot" as |t|}} {{#t.head}} + {{#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}} diff --git a/ui/app/templates/components/allocation-row.hbs b/ui/app/templates/components/allocation-row.hbs index b2f7e1103..1da565ff5 100644 --- a/ui/app/templates/components/allocation-row.hbs +++ b/ui/app/templates/components/allocation-row.hbs @@ -1,6 +1,11 @@ + {{#if allocation.unhealthyDrivers.length}} + + {{x-icon "warning" class="is-warning"}} + + {{/if}} {{#if allocation.nextAllocation}} - + {{x-icon "history" class="is-faded"}} {{/if}} diff --git a/ui/app/templates/components/client-node-row.hbs b/ui/app/templates/components/client-node-row.hbs index 184a00e2b..9a946006d 100644 --- a/ui/app/templates/components/client-node-row.hbs +++ b/ui/app/templates/components/client-node-row.hbs @@ -1,3 +1,10 @@ + + {{#if node.unhealthyDrivers.length}} + + {{x-icon "warning" class="is-warning"}} + + {{/if}} + {{#link-to "clients.client" node.id class="is-primary"}}{{node.shortId}}{{/link-to}} {{node.name}} {{node.status}} diff --git a/ui/app/templates/components/list-accordion.hbs b/ui/app/templates/components/list-accordion.hbs new file mode 100644 index 000000000..67678cef2 --- /dev/null +++ b/ui/app/templates/components/list-accordion.hbs @@ -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}} diff --git a/ui/app/templates/components/list-accordion/accordion-body.hbs b/ui/app/templates/components/list-accordion/accordion-body.hbs new file mode 100644 index 000000000..35512b2b7 --- /dev/null +++ b/ui/app/templates/components/list-accordion/accordion-body.hbs @@ -0,0 +1,5 @@ +{{#if isOpen}} +
+ {{yield}} +
+{{/if}} diff --git a/ui/app/templates/components/list-accordion/accordion-head.hbs b/ui/app/templates/components/list-accordion/accordion-head.hbs new file mode 100644 index 000000000..5d5cab568 --- /dev/null +++ b/ui/app/templates/components/list-accordion/accordion-head.hbs @@ -0,0 +1,9 @@ +
+ {{yield}} +
+ diff --git a/ui/app/templates/components/two-step-button.hbs b/ui/app/templates/components/two-step-button.hbs index 88a509781..a7762124b 100644 --- a/ui/app/templates/components/two-step-button.hbs +++ b/ui/app/templates/components/two-step-button.hbs @@ -1,5 +1,5 @@ {{#if isIdle}} - {{else if isPendingConfirmation}} diff --git a/ui/mirage/factories/node-event.js b/ui/mirage/factories/node-event.js new file mode 100644 index 000000000..a00435d56 --- /dev/null +++ b/ui/mirage/factories/node-event.js @@ -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, +}); diff --git a/ui/mirage/factories/node.js b/ui/mirage/factories/node.js index ba4bd569e..3950f3692 100644 --- a/ui/mirage/factories/node.js +++ b/ui/mirage/factories/node.js @@ -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'), + }; +} diff --git a/ui/mirage/factories/task-event.js b/ui/mirage/factories/task-event.js index ab161cdb2..72397110c 100644 --- a/ui/mirage/factories/task-event.js +++ b/ui/mirage/factories/task-event.js @@ -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(); diff --git a/ui/mirage/factories/task.js b/ui/mirage/factories/task.js index 47afe3bbd..49733e938 100644 --- a/ui/mirage/factories/task.js +++ b/ui/mirage/factories/task.js @@ -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, }); diff --git a/ui/mirage/models/node.js b/ui/mirage/models/node.js new file mode 100644 index 000000000..e42622483 --- /dev/null +++ b/ui/mirage/models/node.js @@ -0,0 +1,5 @@ +import { Model, hasMany } from 'ember-cli-mirage'; + +export default Model.extend({ + events: hasMany('node-event'), +}); diff --git a/ui/mirage/serializers/node.js b/ui/mirage/serializers/node.js new file mode 100644 index 000000000..506c19038 --- /dev/null +++ b/ui/mirage/serializers/node.js @@ -0,0 +1,6 @@ +import ApplicationSerializer from './application'; + +export default ApplicationSerializer.extend({ + embed: true, + include: ['events'], +}); diff --git a/ui/package.json b/ui/package.json index 649c495a3..566b798c9 100644 --- a/ui/package.json +++ b/ui/package.json @@ -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" }, diff --git a/ui/tests/acceptance/allocation-detail-test.js b/ui/tests/acceptance/allocation-detail-test.js index 6218946b4..bfb8821fd 100644 --- a/ui/tests/acceptance/allocation-detail-test.js +++ b/ui/tests/acceptance/allocation-detail-test.js @@ -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'); }); diff --git a/ui/tests/acceptance/client-detail-test.js b/ui/tests/acceptance/client-detail-test.js index a57b89cf6..357779a56 100644 --- a/ui/tests/acceptance/client-detail-test.js +++ b/ui/tests/acceptance/client-detail-test.js @@ -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' + ); + }); +}); diff --git a/ui/tests/acceptance/task-group-detail-test.js b/ui/tests/acceptance/task-group-detail-test.js index 81bcf464a..7db6f6f27 100644 --- a/ui/tests/acceptance/task-group-detail-test.js +++ b/ui/tests/acceptance/task-group-detail-test.js @@ -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'); }); diff --git a/ui/tests/integration/allocation-row-test.js b/ui/tests/integration/allocation-row-test.js index dd6478f40..3049737f2 100644 --- a/ui/tests/integration/allocation-row-test.js +++ b/ui/tests/integration/allocation-row-test.js @@ -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'); + }); +}); diff --git a/ui/tests/unit/adapters/job-test.js b/ui/tests/unit/adapters/job-test.js index b454cc8b1..aa6827d5a 100644 --- a/ui/tests/unit/adapters/job-test.js +++ b/ui/tests/unit/adapters/job-test.js @@ -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( { diff --git a/ui/tests/unit/adapters/node-test.js b/ui/tests/unit/adapters/node-test.js index fde514edd..048f8d3bd 100644 --- a/ui/tests/unit/adapters/node-test.js +++ b/ui/tests/unit/adapters/node-test.js @@ -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(); diff --git a/ui/tests/unit/serializers/node-test.js b/ui/tests/unit/serializers/node-test.js index f474db963..a77d59af3 100644 --- a/ui/tests/unit/serializers/node-test.js +++ b/ui/tests/unit/serializers/node-test.js @@ -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) { diff --git a/ui/yarn.lock b/ui/yarn.lock index 6f921b680..795e0cf81 100644 --- a/ui/yarn.lock +++ b/ui/yarn.lock @@ -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" diff --git a/website/source/docs/agent/configuration/tls.html.md b/website/source/docs/agent/configuration/tls.html.md index 8979610a7..a5f049bea 100644 --- a/website/source/docs/agent/configuration/tls.html.md +++ b/website/source/docs/agent/configuration/tls.html.md @@ -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: [])` - 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