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}} +