diff --git a/.travis.yml b/.travis.yml index 258b59a16..a2c460253 100644 --- a/.travis.yml +++ b/.travis.yml @@ -6,6 +6,9 @@ language: go go: - 1.9.x +addons: + chrome: stable + git: depth: 300 @@ -28,13 +31,11 @@ matrix: - os: osx fast_finish: true -cache: - directories: - - ui/node_modules - before_install: - if [[ "$TRAVIS_OS_NAME" == "osx" ]] && [[ -z "$SKIP_NOMAD_TESTS" ]]; then sudo -E bash ./scripts/travis-mac-priv.sh ; fi - if [[ "$TRAVIS_OS_NAME" == "linux" ]] && [[ -z "$SKIP_NOMAD_TESTS" ]]; then sudo -E bash ./scripts/travis-linux.sh ; fi + - if [[ "$RUN_UI_TESTS" ]]; then curl -o- -L https://yarnpkg.com/install.sh | bash -s -- --version 1.0.1 ; fi + - if [[ "$RUN_UI_TESTS" ]]; then export PATH="$HOME/.yarn/bin:$PATH" ; fi install: - if [[ -z "$SKIP_NOMAD_TESTS" ]]; then make deps ; fi diff --git a/CHANGELOG.md b/CHANGELOG.md index e33f99349..cede06684 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ __BACKWARDS INCOMPATIBILITIES:__ that absolute URLs are not allowed, but it was not enforced. Absolute URLs in HTTP check paths will now fail to validate. [[GH-3685](https://github.com/hashicorp/nomad/issues/3685)] +IMPROVEMENTS: + * core: A set of features (Autopilot) has been added to allow for automatic operator-friendly management of Nomad servers. For more information about Autopilot, see the [Autopilot Guide](https://www.nomadproject.io/guides/cluster/autopilot.html). [[GH-3670](https://github.com/hashicorp/nomad/pull/3670)] + * discovery: Allow `check_restart` to be specified in the `service` stanza. + [[GH-3718](https://github.com/hashicorp/nomad/issues/3718)] + * driver/lxc: Add volumes config to LXC driver [GH-3687] + BUG FIXES: * core: Fix search endpoint forwarding for multi-region clusters [[GH-3680](https://github.com/hashicorp/nomad/issues/3680)] * core: Fix an issue in which batch jobs with queued placements and lost @@ -665,7 +671,7 @@ BUG FIXES: * client: Killing an allocation doesn't cause allocation stats to block [[GH-1454](https://github.com/hashicorp/nomad/issues/1454)] * driver/docker: Disable swap on docker driver [[GH-1480](https://github.com/hashicorp/nomad/issues/1480)] - * driver/docker: Fix improper gating on privileged mode [[GH-1506](https://github.com/hashicorp/nomad/issues/1506)] + * driver/docker: Fix improper gating on priviledged mode [[GH-1506](https://github.com/hashicorp/nomad/issues/1506)] * driver/docker: Default network type is "nat" on Windows [[GH-1521](https://github.com/hashicorp/nomad/issues/1521)] * driver/docker: Cleanup created volume when destroying container [[GH-1519](https://github.com/hashicorp/nomad/issues/1519)] * driver/rkt: Set host environment variables [[GH-1581](https://github.com/hashicorp/nomad/issues/1581)] diff --git a/GNUmakefile b/GNUmakefile index 3c9741a71..951e7a81b 100644 --- a/GNUmakefile +++ b/GNUmakefile @@ -283,8 +283,8 @@ static-assets: ## Compile the static routes to serve alongside the API .PHONY: test-ui test-ui: ## Run Nomad UI test suite @echo "--> Installing JavaScript assets" + @cd ui && npm rebuild node-sass @cd ui && yarn install - @cd ui && npm install phantomjs-prebuilt @echo "--> Running ember tests" @cd ui && phantomjs --version @cd ui && npm test diff --git a/api/jobs_test.go b/api/jobs_test.go index de4ae634d..da7bfc99b 100644 --- a/api/jobs_test.go +++ b/api/jobs_test.go @@ -258,7 +258,7 @@ func TestJobs_Canonicalize(t *testing.T) { }, Services: []*Service{ { - Name: "global-redis-check", + Name: "redis-cache", Tags: []string{"global", "cache"}, PortLabel: "db", Checks: []ServiceCheck{ @@ -368,7 +368,7 @@ func TestJobs_Canonicalize(t *testing.T) { }, Services: []*Service{ { - Name: "global-redis-check", + Name: "redis-cache", Tags: []string{"global", "cache"}, PortLabel: "db", AddressMode: "auto", diff --git a/api/operator.go b/api/operator.go index d1761569c..65fca2322 100644 --- a/api/operator.go +++ b/api/operator.go @@ -76,8 +76,6 @@ func (op *Operator) RaftRemovePeerByAddress(address string, q *WriteOptions) err } r.setWriteOptions(q) - // TODO (alexdadgar) Currently we made address a query parameter. Once - // IDs are in place this will be DELETE /v1/operator/raft/peer/. r.params.Set("address", address) _, resp, err := requireOK(op.c.doRequest(r)) @@ -88,3 +86,23 @@ func (op *Operator) RaftRemovePeerByAddress(address string, q *WriteOptions) err resp.Body.Close() return nil } + +// RaftRemovePeerByID is used to kick a stale peer (one that is in the Raft +// quorum but no longer known to Serf or the catalog) by ID. +func (op *Operator) RaftRemovePeerByID(id string, q *WriteOptions) error { + r, err := op.c.newRequest("DELETE", "/v1/operator/raft/peer") + if err != nil { + return err + } + r.setWriteOptions(q) + + r.params.Set("id", id) + + _, resp, err := requireOK(op.c.doRequest(r)) + if err != nil { + return err + } + + resp.Body.Close() + return nil +} diff --git a/api/operator_autopilot.go b/api/operator_autopilot.go new file mode 100644 index 000000000..a61ad21d6 --- /dev/null +++ b/api/operator_autopilot.go @@ -0,0 +1,232 @@ +package api + +import ( + "bytes" + "fmt" + "io" + "strconv" + "strings" + "time" +) + +// AutopilotConfiguration is used for querying/setting the Autopilot configuration. +// Autopilot helps manage operator tasks related to Nomad servers like removing +// failed servers from the Raft quorum. +type AutopilotConfiguration struct { + // CleanupDeadServers controls whether to remove dead servers from the Raft + // peer list when a new server joins + CleanupDeadServers bool + + // LastContactThreshold is the limit on the amount of time a server can go + // without leader contact before being considered unhealthy. + LastContactThreshold *ReadableDuration + + // MaxTrailingLogs is the amount of entries in the Raft Log that a server can + // be behind before being considered unhealthy. + MaxTrailingLogs uint64 + + // ServerStabilizationTime is the minimum amount of time a server must be + // in a stable, healthy state before it can be added to the cluster. Only + // applicable with Raft protocol version 3 or higher. + ServerStabilizationTime *ReadableDuration + + // (Enterprise-only) RedundancyZoneTag is the node tag to use for separating + // servers into zones for redundancy. If left blank, this feature will be disabled. + RedundancyZoneTag string + + // (Enterprise-only) DisableUpgradeMigration will disable Autopilot's upgrade migration + // strategy of waiting until enough newer-versioned servers have been added to the + // cluster before promoting them to voters. + DisableUpgradeMigration bool + + // (Enterprise-only) UpgradeVersionTag is the node tag to use for version info when + // performing upgrade migrations. If left blank, the Nomad version will be used. + UpgradeVersionTag string + + // CreateIndex holds the index corresponding the creation of this configuration. + // This is a read-only field. + CreateIndex uint64 + + // ModifyIndex will be set to the index of the last update when retrieving the + // Autopilot configuration. Resubmitting a configuration with + // AutopilotCASConfiguration will perform a check-and-set operation which ensures + // there hasn't been a subsequent update since the configuration was retrieved. + ModifyIndex uint64 +} + +// ServerHealth is the health (from the leader's point of view) of a server. +type ServerHealth struct { + // ID is the raft ID of the server. + ID string + + // Name is the node name of the server. + Name string + + // Address is the address of the server. + Address string + + // The status of the SerfHealth check for the server. + SerfStatus string + + // Version is the Nomad version of the server. + Version string + + // Leader is whether this server is currently the leader. + Leader bool + + // LastContact is the time since this node's last contact with the leader. + LastContact *ReadableDuration + + // LastTerm is the highest leader term this server has a record of in its Raft log. + LastTerm uint64 + + // LastIndex is the last log index this server has a record of in its Raft log. + LastIndex uint64 + + // Healthy is whether or not the server is healthy according to the current + // Autopilot config. + Healthy bool + + // Voter is whether this is a voting server. + Voter bool + + // StableSince is the last time this server's Healthy value changed. + StableSince time.Time +} + +// OperatorHealthReply is a representation of the overall health of the cluster +type OperatorHealthReply struct { + // Healthy is true if all the servers in the cluster are healthy. + Healthy bool + + // FailureTolerance is the number of healthy servers that could be lost without + // an outage occurring. + FailureTolerance int + + // Servers holds the health of each server. + Servers []ServerHealth +} + +// ReadableDuration is a duration type that is serialized to JSON in human readable format. +type ReadableDuration time.Duration + +func NewReadableDuration(dur time.Duration) *ReadableDuration { + d := ReadableDuration(dur) + return &d +} + +func (d *ReadableDuration) String() string { + return d.Duration().String() +} + +func (d *ReadableDuration) Duration() time.Duration { + if d == nil { + return time.Duration(0) + } + return time.Duration(*d) +} + +func (d *ReadableDuration) MarshalJSON() ([]byte, error) { + return []byte(fmt.Sprintf(`"%s"`, d.Duration().String())), nil +} + +func (d *ReadableDuration) UnmarshalJSON(raw []byte) error { + if d == nil { + return fmt.Errorf("cannot unmarshal to nil pointer") + } + + str := string(raw) + if len(str) < 2 || str[0] != '"' || str[len(str)-1] != '"' { + return fmt.Errorf("must be enclosed with quotes: %s", str) + } + dur, err := time.ParseDuration(str[1 : len(str)-1]) + if err != nil { + return err + } + *d = ReadableDuration(dur) + return nil +} + +// AutopilotGetConfiguration is used to query the current Autopilot configuration. +func (op *Operator) AutopilotGetConfiguration(q *QueryOptions) (*AutopilotConfiguration, error) { + r, err := op.c.newRequest("GET", "/v1/operator/autopilot/configuration") + if err != nil { + return nil, err + } + r.setQueryOptions(q) + _, resp, err := requireOK(op.c.doRequest(r)) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + var out AutopilotConfiguration + if err := decodeBody(resp, &out); err != nil { + return nil, err + } + + return &out, nil +} + +// AutopilotSetConfiguration is used to set the current Autopilot configuration. +func (op *Operator) AutopilotSetConfiguration(conf *AutopilotConfiguration, q *WriteOptions) error { + r, err := op.c.newRequest("PUT", "/v1/operator/autopilot/configuration") + if err != nil { + return err + } + r.setWriteOptions(q) + r.obj = conf + _, resp, err := requireOK(op.c.doRequest(r)) + if err != nil { + return err + } + resp.Body.Close() + return nil +} + +// AutopilotCASConfiguration is used to perform a Check-And-Set update on the +// Autopilot configuration. The ModifyIndex value will be respected. Returns +// true on success or false on failures. +func (op *Operator) AutopilotCASConfiguration(conf *AutopilotConfiguration, q *WriteOptions) (bool, error) { + r, err := op.c.newRequest("PUT", "/v1/operator/autopilot/configuration") + if err != nil { + return false, err + } + r.setWriteOptions(q) + r.params.Set("cas", strconv.FormatUint(conf.ModifyIndex, 10)) + r.obj = conf + _, resp, err := requireOK(op.c.doRequest(r)) + if err != nil { + return false, err + } + defer resp.Body.Close() + + var buf bytes.Buffer + if _, err := io.Copy(&buf, resp.Body); err != nil { + return false, fmt.Errorf("Failed to read response: %v", err) + } + res := strings.Contains(buf.String(), "true") + + return res, nil +} + +// AutopilotServerHealth is used to query Autopilot's top-level view of the health +// of each Nomad server. +func (op *Operator) AutopilotServerHealth(q *QueryOptions) (*OperatorHealthReply, error) { + r, err := op.c.newRequest("GET", "/v1/operator/autopilot/health") + if err != nil { + return nil, err + } + r.setQueryOptions(q) + _, resp, err := requireOK(op.c.doRequest(r)) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + var out OperatorHealthReply + if err := decodeBody(resp, &out); err != nil { + return nil, err + } + return &out, nil +} diff --git a/api/operator_autopilot_test.go b/api/operator_autopilot_test.go new file mode 100644 index 000000000..1c18e8e0f --- /dev/null +++ b/api/operator_autopilot_test.go @@ -0,0 +1,89 @@ +package api + +import ( + "testing" + + "fmt" + + "github.com/hashicorp/consul/testutil/retry" + "github.com/hashicorp/nomad/testutil" + "github.com/stretchr/testify/assert" +) + +func TestAPI_OperatorAutopilotGetSetConfiguration(t *testing.T) { + t.Parallel() + assert := assert.New(t) + c, s := makeClient(t, nil, nil) + defer s.Stop() + + operator := c.Operator() + config, err := operator.AutopilotGetConfiguration(nil) + assert.Nil(err) + assert.True(config.CleanupDeadServers) + + // Change a config setting + newConf := &AutopilotConfiguration{CleanupDeadServers: false} + err = operator.AutopilotSetConfiguration(newConf, nil) + assert.Nil(err) + + config, err = operator.AutopilotGetConfiguration(nil) + assert.Nil(err) + assert.False(config.CleanupDeadServers) +} + +func TestAPI_OperatorAutopilotCASConfiguration(t *testing.T) { + t.Parallel() + assert := assert.New(t) + c, s := makeClient(t, nil, nil) + defer s.Stop() + + operator := c.Operator() + config, err := operator.AutopilotGetConfiguration(nil) + assert.Nil(err) + assert.True(config.CleanupDeadServers) + + // Pass an invalid ModifyIndex + { + newConf := &AutopilotConfiguration{ + CleanupDeadServers: false, + ModifyIndex: config.ModifyIndex - 1, + } + resp, err := operator.AutopilotCASConfiguration(newConf, nil) + assert.Nil(err) + assert.False(resp) + } + + // Pass a valid ModifyIndex + { + newConf := &AutopilotConfiguration{ + CleanupDeadServers: false, + ModifyIndex: config.ModifyIndex, + } + resp, err := operator.AutopilotCASConfiguration(newConf, nil) + assert.Nil(err) + assert.True(resp) + } +} + +func TestAPI_OperatorAutopilotServerHealth(t *testing.T) { + t.Parallel() + c, s := makeClient(t, nil, func(c *testutil.TestServerConfig) { + c.AdvertiseAddrs.RPC = "127.0.0.1" + c.Server.RaftProtocol = 3 + }) + defer s.Stop() + + operator := c.Operator() + retry.Run(t, func(r *retry.R) { + out, err := operator.AutopilotServerHealth(nil) + if err != nil { + r.Fatalf("err: %v", err) + } + + if len(out.Servers) != 1 || + !out.Servers[0].Healthy || + out.Servers[0].Name != fmt.Sprintf("%s.global", s.Config.NodeName) { + r.Fatalf("bad: %v", out) + } + }) +} diff --git a/api/operator_test.go b/api/operator_test.go index 004cb64a7..5b13fc66c 100644 --- a/api/operator_test.go +++ b/api/operator_test.go @@ -36,3 +36,18 @@ func TestOperator_RaftRemovePeerByAddress(t *testing.T) { t.Fatalf("err: %v", err) } } + +func TestOperator_RaftRemovePeerByID(t *testing.T) { + t.Parallel() + c, s := makeClient(t, nil, nil) + defer s.Stop() + + // If we get this error, it proves we sent the address all the way + // through. + operator := c.Operator() + err := operator.RaftRemovePeerByID("nope", nil) + if err == nil || !strings.Contains(err.Error(), + "id \"nope\" was not found in the Raft configuration") { + t.Fatalf("err: %v", err) + } +} diff --git a/api/tasks.go b/api/tasks.go index 7dc2950b1..a7e3de40a 100644 --- a/api/tasks.go +++ b/api/tasks.go @@ -128,15 +128,15 @@ func (c *CheckRestart) Merge(o *CheckRestart) *CheckRestart { return nc } - if nc.Limit == 0 { + if o.Limit > 0 { nc.Limit = o.Limit } - if nc.Grace == nil { + if o.Grace != nil { nc.Grace = o.Grace } - if nc.IgnoreWarnings { + if o.IgnoreWarnings { nc.IgnoreWarnings = o.IgnoreWarnings } @@ -185,13 +185,11 @@ func (s *Service) Canonicalize(t *Task, tg *TaskGroup, job *Job) { s.AddressMode = "auto" } - s.CheckRestart.Canonicalize() - // Canonicallize CheckRestart on Checks and merge Service.CheckRestart // into each check. - for _, c := range s.Checks { - c.CheckRestart.Canonicalize() - c.CheckRestart = c.CheckRestart.Merge(s.CheckRestart) + for i, check := range s.Checks { + s.Checks[i].CheckRestart = s.CheckRestart.Merge(check.CheckRestart) + s.Checks[i].CheckRestart.Canonicalize() } } diff --git a/api/tasks_test.go b/api/tasks_test.go index d870eab27..7542c6094 100644 --- a/api/tasks_test.go +++ b/api/tasks_test.go @@ -3,6 +3,7 @@ package api import ( "reflect" "testing" + "time" "github.com/hashicorp/nomad/helper" "github.com/stretchr/testify/assert" @@ -266,3 +267,51 @@ func TestTaskGroup_Canonicalize_Update(t *testing.T) { tg.Canonicalize(job) assert.Nil(t, tg.Update) } + +// TestService_CheckRestart asserts Service.CheckRestart settings are properly +// inherited by Checks. +func TestService_CheckRestart(t *testing.T) { + job := &Job{Name: helper.StringToPtr("job")} + tg := &TaskGroup{Name: helper.StringToPtr("group")} + task := &Task{Name: "task"} + service := &Service{ + CheckRestart: &CheckRestart{ + Limit: 11, + Grace: helper.TimeToPtr(11 * time.Second), + IgnoreWarnings: true, + }, + Checks: []ServiceCheck{ + { + Name: "all-set", + CheckRestart: &CheckRestart{ + Limit: 22, + Grace: helper.TimeToPtr(22 * time.Second), + IgnoreWarnings: true, + }, + }, + { + Name: "some-set", + CheckRestart: &CheckRestart{ + Limit: 33, + Grace: helper.TimeToPtr(33 * time.Second), + }, + }, + { + Name: "unset", + }, + }, + } + + service.Canonicalize(task, tg, job) + assert.Equal(t, service.Checks[0].CheckRestart.Limit, 22) + assert.Equal(t, *service.Checks[0].CheckRestart.Grace, 22*time.Second) + assert.True(t, service.Checks[0].CheckRestart.IgnoreWarnings) + + assert.Equal(t, service.Checks[1].CheckRestart.Limit, 33) + assert.Equal(t, *service.Checks[1].CheckRestart.Grace, 33*time.Second) + assert.True(t, service.Checks[1].CheckRestart.IgnoreWarnings) + + assert.Equal(t, service.Checks[2].CheckRestart.Limit, 11) + assert.Equal(t, *service.Checks[2].CheckRestart.Grace, 11*time.Second) + assert.True(t, service.Checks[2].CheckRestart.IgnoreWarnings) +} diff --git a/client/driver/lxc.go b/client/driver/lxc.go index 36c6e0e99..fefb6f2fb 100644 --- a/client/driver/lxc.go +++ b/client/driver/lxc.go @@ -31,6 +31,11 @@ const ( // Config.Options map. lxcConfigOption = "driver.lxc.enable" + // lxcVolumesConfigOption is the key for enabling the use of + // custom bind volumes to arbitrary host paths + lxcVolumesConfigOption = "lxc.volumes.enabled" + lxcVolumesConfigDefault = true + // containerMonitorIntv is the interval at which the driver checks if the // container is still alive containerMonitorIntv = 2 * time.Second @@ -69,6 +74,7 @@ type LxcDriverConfig struct { TemplateArgs []string `mapstructure:"template_args"` LogLevel string `mapstructure:"log_level"` Verbosity string + Volumes []string `mapstructure:"volumes"` } // NewLxcDriver returns a new instance of the LXC driver @@ -137,6 +143,10 @@ func (d *LxcDriver) Validate(config map[string]interface{}) error { Type: fields.TypeString, Required: false, }, + "volumes": { + Type: fields.TypeArray, + Required: false, + }, }, } @@ -144,6 +154,21 @@ func (d *LxcDriver) Validate(config map[string]interface{}) error { return err } + volumes, _ := fd.GetOk("volumes") + for _, volDesc := range volumes.([]interface{}) { + volStr := volDesc.(string) + paths := strings.Split(volStr, ":") + if len(paths) != 2 { + return fmt.Errorf("invalid volume bind mount entry: '%s'", volStr) + } + if len(paths[0]) == 0 || len(paths[1]) == 0 { + return fmt.Errorf("invalid volume bind mount entry: '%s'", volStr) + } + if paths[1][0] == '/' { + return fmt.Errorf("unsupported absolute container mount point: '%s'", paths[1]) + } + } + return nil } @@ -170,6 +195,12 @@ func (d *LxcDriver) Fingerprint(cfg *config.Config, node *structs.Node) (bool, e } node.Attributes["driver.lxc.version"] = version node.Attributes["driver.lxc"] = "1" + + // Advertise if this node supports lxc volumes + if d.config.ReadBoolDefault(lxcVolumesConfigOption, lxcVolumesConfigDefault) { + node.Attributes["driver."+lxcVolumesConfigOption] = "1" + } + return true, nil } @@ -250,6 +281,25 @@ func (d *LxcDriver) Start(ctx *ExecContext, task *structs.Task) (*StartResponse, fmt.Sprintf("%s alloc none rw,bind,create=dir", ctx.TaskDir.SharedAllocDir), fmt.Sprintf("%s secrets none rw,bind,create=dir", ctx.TaskDir.SecretsDir), } + + volumesEnabled := d.config.ReadBoolDefault(lxcVolumesConfigOption, lxcVolumesConfigDefault) + + for _, volDesc := range driverConfig.Volumes { + // the format was checked in Validate() + paths := strings.Split(volDesc, ":") + + if filepath.IsAbs(paths[0]) { + if !volumesEnabled { + return nil, fmt.Errorf("absolute bind-mount volume in config but '%v' is false", lxcVolumesConfigOption) + } + } else { + // Relative source paths are treated as relative to alloc dir + paths[0] = filepath.Join(ctx.TaskDir.Dir, paths[0]) + } + + mounts = append(mounts, fmt.Sprintf("%s %s none rw,bind,create=dir", paths[0], paths[1])) + } + for _, mnt := range mounts { if err := c.SetConfigItem("lxc.mount.entry", mnt); err != nil { return nil, fmt.Errorf("error setting bind mount %q error: %v", mnt, err) diff --git a/client/driver/lxc_test.go b/client/driver/lxc_test.go index e9de2dab7..ddc78193c 100644 --- a/client/driver/lxc_test.go +++ b/client/driver/lxc_test.go @@ -3,8 +3,11 @@ package driver import ( + "bytes" "fmt" + "io/ioutil" "os" + "os/exec" "path/filepath" "testing" "time" @@ -69,11 +72,25 @@ func TestLxcDriver_Start_Wait(t *testing.T) { Driver: "lxc", Config: map[string]interface{}{ "template": "/usr/share/lxc/templates/lxc-busybox", + "volumes": []string{"/tmp/:mnt/tmp"}, }, KillTimeout: 10 * time.Second, Resources: structs.DefaultResources(), } + testFileContents := []byte("this should be visible under /mnt/tmp") + tmpFile, err := ioutil.TempFile("/tmp", "testlxcdriver_start_wait") + if err != nil { + t.Fatalf("error writing temp file: %v", err) + } + defer os.Remove(tmpFile.Name()) + if _, err := tmpFile.Write(testFileContents); err != nil { + t.Fatalf("error writing temp file: %v", err) + } + if err := tmpFile.Close(); err != nil { + t.Fatalf("error closing temp file: %v", err) + } + ctx := testDriverContexts(t, task) defer ctx.AllocDir.Destroy() d := NewLxcDriver(ctx.DriverCtx) @@ -106,7 +123,7 @@ func TestLxcDriver_Start_Wait(t *testing.T) { // Look for mounted directories in their proper location containerName := fmt.Sprintf("%s-%s", task.Name, ctx.DriverCtx.allocID) - for _, mnt := range []string{"alloc", "local", "secrets"} { + for _, mnt := range []string{"alloc", "local", "secrets", "mnt/tmp"} { fullpath := filepath.Join(lxcHandle.lxcPath, containerName, "rootfs", mnt) stat, err := os.Stat(fullpath) if err != nil { @@ -117,6 +134,16 @@ func TestLxcDriver_Start_Wait(t *testing.T) { } } + // Test that /mnt/tmp/$tempFile exists in the container: + mountedContents, err := exec.Command("lxc-attach", "-n", containerName, "--", "cat", filepath.Join("/mnt/", tmpFile.Name())).Output() + if err != nil { + t.Fatalf("err reading temp file in bind mount: %v", err) + } + + if !bytes.Equal(mountedContents, testFileContents) { + t.Fatalf("contents of temp bind mounted file did not match, was '%s'", mountedContents) + } + // Desroy the container if err := sresp.Handle.Kill(); err != nil { t.Fatalf("err: %v", err) @@ -200,3 +227,98 @@ func TestLxcDriver_Open_Wait(t *testing.T) { func lxcPresent(t *testing.T) bool { return lxc.Version() != "" } + +func TestLxcDriver_Volumes_ConfigValidation(t *testing.T) { + if !testutil.IsTravis() { + t.Parallel() + } + if !lxcPresent(t) { + t.Skip("lxc not present") + } + ctestutil.RequireRoot(t) + + brokenVolumeConfigs := [][]string{ + { + "foo:/var", + }, + { + ":", + }, + { + "abc:", + }, + { + ":def", + }, + { + "abc:def:ghi", + }, + } + + for _, bc := range brokenVolumeConfigs { + if err := testVolumeConfig(t, bc); err == nil { + t.Fatalf("error expected in validate for config %+v", bc) + } + } + if err := testVolumeConfig(t, []string{"abc:def"}); err != nil { + t.Fatalf("error in validate for syntactically valid config abc:def") + } +} + +func testVolumeConfig(t *testing.T, volConfig []string) error { + task := &structs.Task{ + Name: "voltest", + Driver: "lxc", + KillTimeout: 10 * time.Second, + Resources: structs.DefaultResources(), + Config: map[string]interface{}{ + "template": "busybox", + }, + } + task.Config["volumes"] = volConfig + + ctx := testDriverContexts(t, task) + defer ctx.AllocDir.Destroy() + + driver := NewLxcDriver(ctx.DriverCtx) + + err := driver.Validate(task.Config) + return err + +} + +func TestLxcDriver_Start_NoVolumes(t *testing.T) { + if !testutil.IsTravis() { + t.Parallel() + } + if !lxcPresent(t) { + t.Skip("lxc not present") + } + ctestutil.RequireRoot(t) + + task := &structs.Task{ + Name: "foo", + Driver: "lxc", + Config: map[string]interface{}{ + "template": "/usr/share/lxc/templates/lxc-busybox", + "volumes": []string{"/tmp/:mnt/tmp"}, + }, + KillTimeout: 10 * time.Second, + Resources: structs.DefaultResources(), + } + + ctx := testDriverContexts(t, task) + defer ctx.AllocDir.Destroy() + + ctx.DriverCtx.config.Options = map[string]string{lxcVolumesConfigOption: "false"} + + d := NewLxcDriver(ctx.DriverCtx) + + if _, err := d.Prestart(ctx.ExecCtx, task); err != nil { + t.Fatalf("prestart err: %v", err) + } + _, err := d.Start(ctx.ExecCtx, task) + if err == nil { + t.Fatalf("expected error in start, got nil.") + } +} diff --git a/command/agent/agent.go b/command/agent/agent.go index de05400fa..470bf875c 100644 --- a/command/agent/agent.go +++ b/command/agent/agent.go @@ -160,6 +160,32 @@ func convertServerConfig(agentConfig *Config, logOutput io.Writer) (*nomad.Confi if agentConfig.Sentinel != nil { conf.SentinelConfig = agentConfig.Sentinel } + if agentConfig.Server.NonVotingServer { + conf.NonVoter = true + } + if agentConfig.Autopilot != nil { + if agentConfig.Autopilot.CleanupDeadServers != nil { + conf.AutopilotConfig.CleanupDeadServers = *agentConfig.Autopilot.CleanupDeadServers + } + if agentConfig.Autopilot.ServerStabilizationTime != 0 { + conf.AutopilotConfig.ServerStabilizationTime = agentConfig.Autopilot.ServerStabilizationTime + } + if agentConfig.Autopilot.LastContactThreshold != 0 { + conf.AutopilotConfig.LastContactThreshold = agentConfig.Autopilot.LastContactThreshold + } + if agentConfig.Autopilot.MaxTrailingLogs != 0 { + conf.AutopilotConfig.MaxTrailingLogs = uint64(agentConfig.Autopilot.MaxTrailingLogs) + } + if agentConfig.Autopilot.RedundancyZoneTag != "" { + conf.AutopilotConfig.RedundancyZoneTag = agentConfig.Autopilot.RedundancyZoneTag + } + if agentConfig.Autopilot.DisableUpgradeMigration != nil { + conf.AutopilotConfig.DisableUpgradeMigration = *agentConfig.Autopilot.DisableUpgradeMigration + } + if agentConfig.Autopilot.UpgradeVersionTag != "" { + conf.AutopilotConfig.UpgradeVersionTag = agentConfig.Autopilot.UpgradeVersionTag + } + } // Set up the bind addresses rpcAddr, err := net.ResolveTCPAddr("tcp", agentConfig.normalizedAddrs.RPC) diff --git a/command/agent/config-test-fixtures/basic.hcl b/command/agent/config-test-fixtures/basic.hcl index 666bdb04a..5cf8603e7 100644 --- a/command/agent/config-test-fixtures/basic.hcl +++ b/command/agent/config-test-fixtures/basic.hcl @@ -67,6 +67,7 @@ server { bootstrap_expect = 5 data_dir = "/tmp/data" protocol_version = 3 + raft_protocol = 3 num_schedulers = 2 enabled_schedulers = ["test"] node_gc_threshold = "12h" @@ -81,6 +82,7 @@ server { retry_max = 3 retry_interval = "15s" rejoin_after_leave = true + non_voting_server = true encrypt = "abc" } acl { @@ -159,3 +161,12 @@ sentinel { args = ["x", "y", "z"] } } +autopilot { + cleanup_dead_servers = true + disable_upgrade_migration = true + last_contact_threshold = "12705s" + max_trailing_logs = 17849 + redundancy_zone_tag = "foo" + server_stabilization_time = "23057s" + upgrade_version_tag = "bar" +} diff --git a/command/agent/config.go b/command/agent/config.go index 8802c19ff..6cff6c378 100644 --- a/command/agent/config.go +++ b/command/agent/config.go @@ -130,6 +130,9 @@ type Config struct { // Sentinel holds sentinel related settings Sentinel *config.SentinelConfig `mapstructure:"sentinel"` + + // Autopilot contains the configuration for Autopilot behavior. + Autopilot *config.AutopilotConfig `mapstructure:"autopilot"` } // ClientConfig is configuration specific to the client mode @@ -327,6 +330,10 @@ type ServerConfig struct { // true, we ignore the leave, and rejoin the cluster on start. RejoinAfterLeave bool `mapstructure:"rejoin_after_leave"` + // NonVotingServer is whether this server will act as a non-voting member + // of the cluster to help provide read scalability. (Enterprise-only) + NonVotingServer bool `mapstructure:"non_voting_server"` + // Encryption key to use for the Serf communication EncryptKey string `mapstructure:"encrypt" json:"-"` } @@ -604,6 +611,7 @@ func DefaultConfig() *Config { TLSConfig: &config.TLSConfig{}, Sentinel: &config.SentinelConfig{}, Version: version.GetVersion(), + Autopilot: config.DefaultAutopilotConfig(), } } @@ -762,6 +770,13 @@ func (c *Config) Merge(b *Config) *Config { result.Sentinel = result.Sentinel.Merge(b.Sentinel) } + if result.Autopilot == nil && b.Autopilot != nil { + autopilot := *b.Autopilot + result.Autopilot = &autopilot + } else if b.Autopilot != nil { + result.Autopilot = result.Autopilot.Merge(b.Autopilot) + } + // Merge config files lists result.Files = append(result.Files, b.Files...) @@ -1016,6 +1031,9 @@ func (a *ServerConfig) Merge(b *ServerConfig) *ServerConfig { if b.RejoinAfterLeave { result.RejoinAfterLeave = true } + if b.NonVotingServer { + result.NonVotingServer = true + } if b.EncryptKey != "" { result.EncryptKey = b.EncryptKey } diff --git a/command/agent/config_parse.go b/command/agent/config_parse.go index 6c63a008d..e860a68af 100644 --- a/command/agent/config_parse.go +++ b/command/agent/config_parse.go @@ -98,6 +98,7 @@ func parseConfig(result *Config, list *ast.ObjectList) error { "http_api_response_headers", "acl", "sentinel", + "autopilot", } if err := helper.CheckHCLKeys(list, valid); err != nil { return multierror.Prefix(err, "config:") @@ -121,6 +122,7 @@ func parseConfig(result *Config, list *ast.ObjectList) error { delete(m, "http_api_response_headers") delete(m, "acl") delete(m, "sentinel") + delete(m, "autopilot") // Decode the rest if err := mapstructure.WeakDecode(m, result); err != nil { @@ -204,6 +206,13 @@ func parseConfig(result *Config, list *ast.ObjectList) error { } } + // Parse Autopilot config + if o := list.Filter("autopilot"); len(o.Items) > 0 { + if err := parseAutopilot(&result.Autopilot, o); err != nil { + return multierror.Prefix(err, "autopilot->") + } + } + // Parse out http_api_response_headers fields. These are in HCL as a list so // we need to iterate over them and merge them. if headersO := list.Filter("http_api_response_headers"); len(headersO.Items) > 0 { @@ -509,6 +518,7 @@ func parseServer(result **ServerConfig, list *ast.ObjectList) error { "bootstrap_expect", "data_dir", "protocol_version", + "raft_protocol", "num_schedulers", "enabled_schedulers", "node_gc_threshold", @@ -525,6 +535,7 @@ func parseServer(result **ServerConfig, list *ast.ObjectList) error { "rejoin_after_leave", "encrypt", "authoritative_region", + "non_voting_server", } if err := helper.CheckHCLKeys(listVal, valid); err != nil { return err @@ -838,3 +849,49 @@ func parseSentinel(result **config.SentinelConfig, list *ast.ObjectList) error { *result = &config return nil } + +func parseAutopilot(result **config.AutopilotConfig, list *ast.ObjectList) error { + list = list.Elem() + if len(list.Items) > 1 { + return fmt.Errorf("only one 'autopilot' block allowed") + } + + // Get our Autopilot object + listVal := list.Items[0].Val + + // Check for invalid keys + valid := []string{ + "cleanup_dead_servers", + "server_stabilization_time", + "last_contact_threshold", + "max_trailing_logs", + "redundancy_zone_tag", + "disable_upgrade_migration", + "upgrade_version_tag", + } + + if err := helper.CheckHCLKeys(listVal, valid); err != nil { + return err + } + + var m map[string]interface{} + if err := hcl.DecodeObject(&m, listVal); err != nil { + return err + } + + autopilotConfig := config.DefaultAutopilotConfig() + dec, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{ + DecodeHook: mapstructure.StringToTimeDurationHookFunc(), + WeaklyTypedInput: true, + Result: &autopilotConfig, + }) + if err != nil { + return err + } + if err := dec.Decode(m); err != nil { + return err + } + + *result = autopilotConfig + return nil +} diff --git a/command/agent/config_parse_test.go b/command/agent/config_parse_test.go index 9a8ef7bb6..c28989d9f 100644 --- a/command/agent/config_parse_test.go +++ b/command/agent/config_parse_test.go @@ -88,6 +88,7 @@ func TestConfig_Parse(t *testing.T) { BootstrapExpect: 5, DataDir: "/tmp/data", ProtocolVersion: 3, + RaftProtocol: 3, NumSchedulers: 2, EnabledSchedulers: []string{"test"}, NodeGCThreshold: "12h", @@ -102,6 +103,7 @@ func TestConfig_Parse(t *testing.T) { RetryInterval: "15s", RejoinAfterLeave: true, RetryMaxAttempts: 3, + NonVotingServer: true, EncryptKey: "abc", }, ACL: &ACLConfig{ @@ -186,6 +188,15 @@ func TestConfig_Parse(t *testing.T) { }, }, }, + Autopilot: &config.AutopilotConfig{ + CleanupDeadServers: &trueValue, + ServerStabilizationTime: 23057 * time.Second, + LastContactThreshold: 12705 * time.Second, + MaxTrailingLogs: 17849, + RedundancyZoneTag: "foo", + DisableUpgradeMigration: &trueValue, + UpgradeVersionTag: "bar", + }, }, false, }, diff --git a/command/agent/config_test.go b/command/agent/config_test.go index 0bd286868..400b57615 100644 --- a/command/agent/config_test.go +++ b/command/agent/config_test.go @@ -35,6 +35,7 @@ func TestConfig_Merge(t *testing.T) { Vault: &config.VaultConfig{}, Consul: &config.ConsulConfig{}, Sentinel: &config.SentinelConfig{}, + Autopilot: &config.AutopilotConfig{}, } c2 := &Config{ @@ -100,6 +101,7 @@ func TestConfig_Merge(t *testing.T) { BootstrapExpect: 1, DataDir: "/tmp/data1", ProtocolVersion: 1, + RaftProtocol: 1, NumSchedulers: 1, NodeGCThreshold: "1h", HeartbeatGrace: 30 * time.Second, @@ -158,6 +160,15 @@ func TestConfig_Merge(t *testing.T) { ClientAutoJoin: &falseValue, ChecksUseAdvertise: &falseValue, }, + Autopilot: &config.AutopilotConfig{ + CleanupDeadServers: &falseValue, + ServerStabilizationTime: 1 * time.Second, + LastContactThreshold: 1 * time.Second, + MaxTrailingLogs: 1, + RedundancyZoneTag: "1", + DisableUpgradeMigration: &falseValue, + UpgradeVersionTag: "1", + }, } c3 := &Config{ @@ -248,6 +259,7 @@ func TestConfig_Merge(t *testing.T) { RetryJoin: []string{"1.1.1.1"}, RetryInterval: "10s", retryInterval: time.Second * 10, + NonVotingServer: true, }, ACL: &ACLConfig{ Enabled: true, @@ -311,6 +323,15 @@ func TestConfig_Merge(t *testing.T) { }, }, }, + Autopilot: &config.AutopilotConfig{ + CleanupDeadServers: &trueValue, + ServerStabilizationTime: 2 * time.Second, + LastContactThreshold: 2 * time.Second, + MaxTrailingLogs: 2, + RedundancyZoneTag: "2", + DisableUpgradeMigration: &trueValue, + UpgradeVersionTag: "2", + }, } result := c0.Merge(c1) diff --git a/command/agent/http.go b/command/agent/http.go index 8aa1b2f09..8dbf62fc8 100644 --- a/command/agent/http.go +++ b/command/agent/http.go @@ -18,6 +18,7 @@ import ( assetfs "github.com/elazarl/go-bindata-assetfs" "github.com/hashicorp/nomad/helper/tlsutil" "github.com/hashicorp/nomad/nomad/structs" + "github.com/mitchellh/mapstructure" "github.com/rs/cors" "github.com/ugorji/go/codec" ) @@ -183,7 +184,9 @@ func (s *HTTPServer) registerHandlers(enableDebug bool) { s.mux.HandleFunc("/v1/search", s.wrap(s.SearchRequest)) - s.mux.HandleFunc("/v1/operator/", s.wrap(s.OperatorRequest)) + s.mux.HandleFunc("/v1/operator/raft/", s.wrap(s.OperatorRequest)) + s.mux.HandleFunc("/v1/operator/autopilot/configuration", s.wrap(s.OperatorAutopilotConfiguration)) + s.mux.HandleFunc("/v1/operator/autopilot/health", s.wrap(s.OperatorServerHealth)) s.mux.HandleFunc("/v1/system/gc", s.wrap(s.GarbageCollectRequest)) s.mux.HandleFunc("/v1/system/reconcile/summaries", s.wrap(s.ReconcileJobSummaries)) @@ -337,6 +340,24 @@ func decodeBody(req *http.Request, out interface{}) error { return dec.Decode(&out) } +// decodeBodyFunc is used to decode a JSON request body invoking +// a given callback function +func decodeBodyFunc(req *http.Request, out interface{}, cb func(interface{}) error) error { + var raw interface{} + dec := json.NewDecoder(req.Body) + if err := dec.Decode(&raw); err != nil { + return err + } + + // Invoke the callback prior to decode + if cb != nil { + if err := cb(raw); err != nil { + return err + } + } + return mapstructure.Decode(raw, out) +} + // setIndex is used to set the index response header func setIndex(resp http.ResponseWriter, index uint64) { resp.Header().Set("X-Nomad-Index", strconv.FormatUint(index, 10)) diff --git a/command/agent/job_endpoint_test.go b/command/agent/job_endpoint_test.go index 019a82ae0..b595e28ab 100644 --- a/command/agent/job_endpoint_test.go +++ b/command/agent/job_endpoint_test.go @@ -1212,6 +1212,10 @@ func TestJobs_ApiJobToStructsJob(t *testing.T) { Name: "serviceA", Tags: []string{"1", "2"}, PortLabel: "foo", + CheckRestart: &api.CheckRestart{ + Limit: 4, + Grace: helper.TimeToPtr(11 * time.Second), + }, Checks: []api.ServiceCheck{ { Id: "hello", @@ -1228,10 +1232,17 @@ func TestJobs_ApiJobToStructsJob(t *testing.T) { InitialStatus: "ok", CheckRestart: &api.CheckRestart{ Limit: 3, - Grace: helper.TimeToPtr(10 * time.Second), IgnoreWarnings: true, }, }, + { + Id: "check2id", + Name: "check2", + Type: "tcp", + PortLabel: "foo", + Interval: 4 * time.Second, + Timeout: 2 * time.Second, + }, }, }, }, @@ -1425,10 +1436,21 @@ func TestJobs_ApiJobToStructsJob(t *testing.T) { InitialStatus: "ok", CheckRestart: &structs.CheckRestart{ Limit: 3, - Grace: 10 * time.Second, + Grace: 11 * time.Second, IgnoreWarnings: true, }, }, + { + Name: "check2", + Type: "tcp", + PortLabel: "foo", + Interval: 4 * time.Second, + Timeout: 2 * time.Second, + CheckRestart: &structs.CheckRestart{ + Limit: 4, + Grace: 11 * time.Second, + }, + }, }, }, }, diff --git a/command/agent/operator_endpoint.go b/command/agent/operator_endpoint.go index 2819aef86..93db317a2 100644 --- a/command/agent/operator_endpoint.go +++ b/command/agent/operator_endpoint.go @@ -4,6 +4,12 @@ import ( "net/http" "strings" + "fmt" + "strconv" + "time" + + "github.com/hashicorp/consul/agent/consul/autopilot" + "github.com/hashicorp/nomad/api" "github.com/hashicorp/nomad/nomad/structs" "github.com/hashicorp/raft" ) @@ -49,21 +55,222 @@ func (s *HTTPServer) OperatorRaftPeer(resp http.ResponseWriter, req *http.Reques return nil, nil } - var args structs.RaftPeerByAddressRequest - s.parseWriteRequest(req, &args.WriteRequest) - params := req.URL.Query() - if _, ok := params["address"]; ok { - args.Address = raft.ServerAddress(params.Get("address")) - } else { + _, hasID := params["id"] + _, hasAddress := params["address"] + + if !hasID && !hasAddress { resp.WriteHeader(http.StatusBadRequest) - resp.Write([]byte("Must specify ?address with IP:port of peer to remove")) + fmt.Fprint(resp, "Must specify either ?id with the server's ID or ?address with IP:port of peer to remove") + return nil, nil + } + if hasID && hasAddress { + resp.WriteHeader(http.StatusBadRequest) + fmt.Fprint(resp, "Must specify only one of ?id or ?address") return nil, nil } - var reply struct{} - if err := s.agent.RPC("Operator.RaftRemovePeerByAddress", &args, &reply); err != nil { - return nil, err + if hasID { + var args structs.RaftPeerByIDRequest + s.parseWriteRequest(req, &args.WriteRequest) + + var reply struct{} + args.ID = raft.ServerID(params.Get("id")) + if err := s.agent.RPC("Operator.RaftRemovePeerByID", &args, &reply); err != nil { + return nil, err + } + } else { + var args structs.RaftPeerByAddressRequest + s.parseWriteRequest(req, &args.WriteRequest) + + var reply struct{} + args.Address = raft.ServerAddress(params.Get("address")) + if err := s.agent.RPC("Operator.RaftRemovePeerByAddress", &args, &reply); err != nil { + return nil, err + } } + return nil, nil } + +// OperatorAutopilotConfiguration is used to inspect the current Autopilot configuration. +// This supports the stale query mode in case the cluster doesn't have a leader. +func (s *HTTPServer) OperatorAutopilotConfiguration(resp http.ResponseWriter, req *http.Request) (interface{}, error) { + // Switch on the method + switch req.Method { + case "GET": + var args structs.GenericRequest + if done := s.parse(resp, req, &args.Region, &args.QueryOptions); done { + return nil, nil + } + + var reply autopilot.Config + if err := s.agent.RPC("Operator.AutopilotGetConfiguration", &args, &reply); err != nil { + return nil, err + } + + out := api.AutopilotConfiguration{ + CleanupDeadServers: reply.CleanupDeadServers, + LastContactThreshold: api.NewReadableDuration(reply.LastContactThreshold), + MaxTrailingLogs: reply.MaxTrailingLogs, + ServerStabilizationTime: api.NewReadableDuration(reply.ServerStabilizationTime), + RedundancyZoneTag: reply.RedundancyZoneTag, + DisableUpgradeMigration: reply.DisableUpgradeMigration, + UpgradeVersionTag: reply.UpgradeVersionTag, + CreateIndex: reply.CreateIndex, + ModifyIndex: reply.ModifyIndex, + } + + return out, nil + + case "PUT": + var args structs.AutopilotSetConfigRequest + s.parseRegion(req, &args.Region) + s.parseToken(req, &args.AuthToken) + + var conf api.AutopilotConfiguration + durations := NewDurationFixer("lastcontactthreshold", "serverstabilizationtime") + if err := decodeBodyFunc(req, &conf, durations.FixupDurations); err != nil { + resp.WriteHeader(http.StatusBadRequest) + fmt.Fprintf(resp, "Error parsing autopilot config: %v", err) + return nil, nil + } + + args.Config = autopilot.Config{ + CleanupDeadServers: conf.CleanupDeadServers, + LastContactThreshold: conf.LastContactThreshold.Duration(), + MaxTrailingLogs: conf.MaxTrailingLogs, + ServerStabilizationTime: conf.ServerStabilizationTime.Duration(), + RedundancyZoneTag: conf.RedundancyZoneTag, + DisableUpgradeMigration: conf.DisableUpgradeMigration, + UpgradeVersionTag: conf.UpgradeVersionTag, + } + + // Check for cas value + params := req.URL.Query() + if _, ok := params["cas"]; ok { + casVal, err := strconv.ParseUint(params.Get("cas"), 10, 64) + if err != nil { + resp.WriteHeader(http.StatusBadRequest) + fmt.Fprintf(resp, "Error parsing cas value: %v", err) + return nil, nil + } + args.Config.ModifyIndex = casVal + args.CAS = true + } + + var reply bool + if err := s.agent.RPC("Operator.AutopilotSetConfiguration", &args, &reply); err != nil { + return nil, err + } + + // Only use the out value if this was a CAS + if !args.CAS { + return true, nil + } + return reply, nil + + default: + resp.WriteHeader(http.StatusMethodNotAllowed) + return nil, nil + } +} + +// OperatorServerHealth is used to get the health of the servers in the given Region. +func (s *HTTPServer) OperatorServerHealth(resp http.ResponseWriter, req *http.Request) (interface{}, error) { + if req.Method != "GET" { + resp.WriteHeader(http.StatusMethodNotAllowed) + return nil, nil + } + + var args structs.GenericRequest + if done := s.parse(resp, req, &args.Region, &args.QueryOptions); done { + return nil, nil + } + + var reply autopilot.OperatorHealthReply + if err := s.agent.RPC("Operator.ServerHealth", &args, &reply); err != nil { + return nil, err + } + + // Reply with status 429 if something is unhealthy + if !reply.Healthy { + resp.WriteHeader(http.StatusTooManyRequests) + } + + out := &api.OperatorHealthReply{ + Healthy: reply.Healthy, + FailureTolerance: reply.FailureTolerance, + } + for _, server := range reply.Servers { + out.Servers = append(out.Servers, api.ServerHealth{ + ID: server.ID, + Name: server.Name, + Address: server.Address, + Version: server.Version, + Leader: server.Leader, + SerfStatus: server.SerfStatus.String(), + LastContact: api.NewReadableDuration(server.LastContact), + LastTerm: server.LastTerm, + LastIndex: server.LastIndex, + Healthy: server.Healthy, + Voter: server.Voter, + StableSince: server.StableSince.Round(time.Second).UTC(), + }) + } + + return out, nil +} + +type durationFixer map[string]bool + +func NewDurationFixer(fields ...string) durationFixer { + d := make(map[string]bool) + for _, field := range fields { + d[field] = true + } + return d +} + +// FixupDurations is used to handle parsing any field names in the map to time.Durations +func (d durationFixer) FixupDurations(raw interface{}) error { + rawMap, ok := raw.(map[string]interface{}) + if !ok { + return nil + } + for key, val := range rawMap { + switch val.(type) { + case map[string]interface{}: + if err := d.FixupDurations(val); err != nil { + return err + } + + case []interface{}: + for _, v := range val.([]interface{}) { + if err := d.FixupDurations(v); err != nil { + return err + } + } + + case []map[string]interface{}: + for _, v := range val.([]map[string]interface{}) { + if err := d.FixupDurations(v); err != nil { + return err + } + } + + default: + if d[strings.ToLower(key)] { + // Convert a string value into an integer + if vStr, ok := val.(string); ok { + dur, err := time.ParseDuration(vStr) + if err != nil { + return err + } + rawMap[key] = dur + } + } + } + } + return nil +} diff --git a/command/agent/operator_endpoint_test.go b/command/agent/operator_endpoint_test.go index b0e4dd651..10ee36821 100644 --- a/command/agent/operator_endpoint_test.go +++ b/command/agent/operator_endpoint_test.go @@ -2,12 +2,18 @@ package agent import ( "bytes" + "fmt" "net/http" "net/http/httptest" "strings" "testing" + "time" + "github.com/hashicorp/consul/agent/consul/autopilot" + "github.com/hashicorp/consul/testutil/retry" + "github.com/hashicorp/nomad/api" "github.com/hashicorp/nomad/nomad/structs" + "github.com/stretchr/testify/assert" ) func TestHTTP_OperatorRaftConfiguration(t *testing.T) { @@ -40,13 +46,12 @@ func TestHTTP_OperatorRaftConfiguration(t *testing.T) { } func TestHTTP_OperatorRaftPeer(t *testing.T) { + assert := assert.New(t) t.Parallel() httpTest(t, nil, func(s *TestAgent) { body := bytes.NewBuffer(nil) req, err := http.NewRequest("DELETE", "/v1/operator/raft/peer?address=nope", body) - if err != nil { - t.Fatalf("err: %v", err) - } + assert.Nil(err) // If we get this error, it proves we sent the address all the // way through. @@ -57,4 +62,244 @@ func TestHTTP_OperatorRaftPeer(t *testing.T) { t.Fatalf("err: %v", err) } }) + + httpTest(t, nil, func(s *TestAgent) { + body := bytes.NewBuffer(nil) + req, err := http.NewRequest("DELETE", "/v1/operator/raft/peer?id=nope", body) + assert.Nil(err) + + // If we get this error, it proves we sent the address all the + // way through. + resp := httptest.NewRecorder() + _, err = s.Server.OperatorRaftPeer(resp, req) + if err == nil || !strings.Contains(err.Error(), + "id \"nope\" was not found in the Raft configuration") { + t.Fatalf("err: %v", err) + } + }) +} + +func TestOperator_AutopilotGetConfiguration(t *testing.T) { + t.Parallel() + httpTest(t, nil, func(s *TestAgent) { + body := bytes.NewBuffer(nil) + req, _ := http.NewRequest("GET", "/v1/operator/autopilot/configuration", body) + resp := httptest.NewRecorder() + obj, err := s.Server.OperatorAutopilotConfiguration(resp, req) + if err != nil { + t.Fatalf("err: %v", err) + } + if resp.Code != 200 { + t.Fatalf("bad code: %d", resp.Code) + } + out, ok := obj.(api.AutopilotConfiguration) + if !ok { + t.Fatalf("unexpected: %T", obj) + } + if !out.CleanupDeadServers { + t.Fatalf("bad: %#v", out) + } + }) +} + +func TestOperator_AutopilotSetConfiguration(t *testing.T) { + t.Parallel() + httpTest(t, nil, func(s *TestAgent) { + body := bytes.NewBuffer([]byte(`{"CleanupDeadServers": false}`)) + req, _ := http.NewRequest("PUT", "/v1/operator/autopilot/configuration", body) + resp := httptest.NewRecorder() + if _, err := s.Server.OperatorAutopilotConfiguration(resp, req); err != nil { + t.Fatalf("err: %v", err) + } + if resp.Code != 200 { + t.Fatalf("bad code: %d", resp.Code) + } + + args := structs.GenericRequest{ + QueryOptions: structs.QueryOptions{ + Region: s.Config.Region, + }, + } + + var reply autopilot.Config + if err := s.RPC("Operator.AutopilotGetConfiguration", &args, &reply); err != nil { + t.Fatalf("err: %v", err) + } + if reply.CleanupDeadServers { + t.Fatalf("bad: %#v", reply) + } + }) +} + +func TestOperator_AutopilotCASConfiguration(t *testing.T) { + t.Parallel() + httpTest(t, nil, func(s *TestAgent) { + body := bytes.NewBuffer([]byte(`{"CleanupDeadServers": false}`)) + req, _ := http.NewRequest("PUT", "/v1/operator/autopilot/configuration", body) + resp := httptest.NewRecorder() + if _, err := s.Server.OperatorAutopilotConfiguration(resp, req); err != nil { + t.Fatalf("err: %v", err) + } + if resp.Code != 200 { + t.Fatalf("bad code: %d", resp.Code) + } + + args := structs.GenericRequest{ + QueryOptions: structs.QueryOptions{ + Region: s.Config.Region, + }, + } + + var reply autopilot.Config + if err := s.RPC("Operator.AutopilotGetConfiguration", &args, &reply); err != nil { + t.Fatalf("err: %v", err) + } + + if reply.CleanupDeadServers { + t.Fatalf("bad: %#v", reply) + } + + // Create a CAS request, bad index + { + buf := bytes.NewBuffer([]byte(`{"CleanupDeadServers": true}`)) + req, _ := http.NewRequest("PUT", fmt.Sprintf("/v1/operator/autopilot/configuration?cas=%d", reply.ModifyIndex-1), buf) + resp := httptest.NewRecorder() + obj, err := s.Server.OperatorAutopilotConfiguration(resp, req) + if err != nil { + t.Fatalf("err: %v", err) + } + + if res := obj.(bool); res { + t.Fatalf("should NOT work") + } + } + + // Create a CAS request, good index + { + buf := bytes.NewBuffer([]byte(`{"CleanupDeadServers": true}`)) + req, _ := http.NewRequest("PUT", fmt.Sprintf("/v1/operator/autopilot/configuration?cas=%d", reply.ModifyIndex), buf) + resp := httptest.NewRecorder() + obj, err := s.Server.OperatorAutopilotConfiguration(resp, req) + if err != nil { + t.Fatalf("err: %v", err) + } + + if res := obj.(bool); !res { + t.Fatalf("should work") + } + } + + // Verify the update + if err := s.RPC("Operator.AutopilotGetConfiguration", &args, &reply); err != nil { + t.Fatalf("err: %v", err) + } + if !reply.CleanupDeadServers { + t.Fatalf("bad: %#v", reply) + } + }) +} + +func TestOperator_ServerHealth(t *testing.T) { + t.Parallel() + httpTest(t, func(c *Config) { + c.Server.RaftProtocol = 3 + }, func(s *TestAgent) { + body := bytes.NewBuffer(nil) + req, _ := http.NewRequest("GET", "/v1/operator/autopilot/health", body) + retry.Run(t, func(r *retry.R) { + resp := httptest.NewRecorder() + obj, err := s.Server.OperatorServerHealth(resp, req) + if err != nil { + r.Fatalf("err: %v", err) + } + if resp.Code != 200 { + r.Fatalf("bad code: %d", resp.Code) + } + out, ok := obj.(*api.OperatorHealthReply) + if !ok { + r.Fatalf("unexpected: %T", obj) + } + if len(out.Servers) != 1 || + !out.Servers[0].Healthy || + out.Servers[0].Name != s.server.LocalMember().Name || + out.Servers[0].SerfStatus != "alive" || + out.FailureTolerance != 0 { + r.Fatalf("bad: %v, %q", out, s.server.LocalMember().Name) + } + }) + }) +} + +func TestOperator_ServerHealth_Unhealthy(t *testing.T) { + t.Parallel() + httpTest(t, func(c *Config) { + c.Server.RaftProtocol = 3 + c.Autopilot.LastContactThreshold = -1 * time.Second + }, func(s *TestAgent) { + body := bytes.NewBuffer(nil) + req, _ := http.NewRequest("GET", "/v1/operator/autopilot/health", body) + retry.Run(t, func(r *retry.R) { + resp := httptest.NewRecorder() + obj, err := s.Server.OperatorServerHealth(resp, req) + if err != nil { + r.Fatalf("err: %v", err) + } + if resp.Code != 429 { + r.Fatalf("bad code: %d, %v", resp.Code, obj.(*api.OperatorHealthReply)) + } + out, ok := obj.(*api.OperatorHealthReply) + if !ok { + r.Fatalf("unexpected: %T", obj) + } + if len(out.Servers) != 1 || + out.Healthy || + out.Servers[0].Name != s.server.LocalMember().Name { + r.Fatalf("bad: %#v", out.Servers) + } + }) + }) +} + +func TestDurationFixer(t *testing.T) { + assert := assert.New(t) + obj := map[string]interface{}{ + "key1": []map[string]interface{}{ + { + "subkey1": "10s", + }, + { + "subkey2": "5d", + }, + }, + "key2": map[string]interface{}{ + "subkey3": "30s", + "subkey4": "20m", + }, + "key3": "11s", + "key4": "49h", + } + expected := map[string]interface{}{ + "key1": []map[string]interface{}{ + { + "subkey1": 10 * time.Second, + }, + { + "subkey2": "5d", + }, + }, + "key2": map[string]interface{}{ + "subkey3": "30s", + "subkey4": 20 * time.Minute, + }, + "key3": "11s", + "key4": 49 * time.Hour, + } + + fixer := NewDurationFixer("key4", "subkey1", "subkey4") + if err := fixer.FixupDurations(obj); err != nil { + t.Fatal(err) + } + + // Ensure we only processed the intended fieldnames + assert.Equal(obj, expected) } diff --git a/command/agent/testagent.go b/command/agent/testagent.go index 2b8fe6c5a..539890004 100644 --- a/command/agent/testagent.go +++ b/command/agent/testagent.go @@ -301,6 +301,11 @@ func (a *TestAgent) config() *Config { config.RaftConfig.StartAsLeader = true config.RaftTimeout = 500 * time.Millisecond + // Tighten the autopilot timing + config.AutopilotConfig.ServerStabilizationTime = 100 * time.Millisecond + config.ServerHealthInterval = 50 * time.Millisecond + config.AutopilotInterval = 100 * time.Millisecond + // Bootstrap ourselves config.Bootstrap = true config.BootstrapExpect = 1 diff --git a/command/init.go b/command/init.go index f9953cde7..519ea8dff 100644 --- a/command/init.go +++ b/command/init.go @@ -310,7 +310,7 @@ job "example" { # https://www.nomadproject.io/docs/job-specification/service.html # service { - name = "global-redis-check" + name = "redis-cache" tags = ["global", "cache"] port = "db" check { diff --git a/command/operator_autopilot.go b/command/operator_autopilot.go new file mode 100644 index 000000000..d4c4cdd5b --- /dev/null +++ b/command/operator_autopilot.go @@ -0,0 +1,29 @@ +package command + +import ( + "strings" + + "github.com/mitchellh/cli" +) + +type OperatorAutopilotCommand struct { + Meta +} + +func (c *OperatorAutopilotCommand) Run(args []string) int { + return cli.RunResultHelp +} + +func (c *OperatorAutopilotCommand) Synopsis() string { + return "Provides tools for modifying Autopilot configuration" +} + +func (c *OperatorAutopilotCommand) Help() string { + helpText := ` +Usage: nomad operator autopilot [options] + + The Autopilot operator command is used to interact with Nomad's Autopilot + subsystem. The command can be used to view or modify the current configuration. +` + return strings.TrimSpace(helpText) +} diff --git a/command/operator_autopilot_get.go b/command/operator_autopilot_get.go new file mode 100644 index 000000000..b533b4749 --- /dev/null +++ b/command/operator_autopilot_get.go @@ -0,0 +1,70 @@ +package command + +import ( + "fmt" + "strings" + + "github.com/posener/complete" +) + +type OperatorAutopilotGetCommand struct { + Meta +} + +func (c *OperatorAutopilotGetCommand) AutocompleteFlags() complete.Flags { + return mergeAutocompleteFlags(c.Meta.AutocompleteFlags(FlagSetClient)) +} + +func (c *OperatorAutopilotGetCommand) AutocompleteArgs() complete.Predictor { + return complete.PredictNothing +} + +func (c *OperatorAutopilotGetCommand) Run(args []string) int { + flags := c.Meta.FlagSet("autopilot", FlagSetClient) + flags.Usage = func() { c.Ui.Output(c.Help()) } + + if err := flags.Parse(args); err != nil { + c.Ui.Error(fmt.Sprintf("Failed to parse args: %v", err)) + return 1 + } + + // Set up a client. + client, err := c.Meta.Client() + if err != nil { + c.Ui.Error(fmt.Sprintf("Error initializing client: %s", err)) + return 1 + } + + // Fetch the current configuration. + config, err := client.Operator().AutopilotGetConfiguration(nil) + if err != nil { + c.Ui.Error(fmt.Sprintf("Error querying Autopilot configuration: %s", err)) + return 1 + } + c.Ui.Output(fmt.Sprintf("CleanupDeadServers = %v", config.CleanupDeadServers)) + c.Ui.Output(fmt.Sprintf("LastContactThreshold = %v", config.LastContactThreshold.String())) + c.Ui.Output(fmt.Sprintf("MaxTrailingLogs = %v", config.MaxTrailingLogs)) + c.Ui.Output(fmt.Sprintf("ServerStabilizationTime = %v", config.ServerStabilizationTime.String())) + c.Ui.Output(fmt.Sprintf("RedundancyZoneTag = %q", config.RedundancyZoneTag)) + c.Ui.Output(fmt.Sprintf("DisableUpgradeMigration = %v", config.DisableUpgradeMigration)) + c.Ui.Output(fmt.Sprintf("UpgradeVersionTag = %q", config.UpgradeVersionTag)) + + return 0 +} + +func (c *OperatorAutopilotGetCommand) Synopsis() string { + return "Display the current Autopilot configuration" +} + +func (c *OperatorAutopilotGetCommand) Help() string { + helpText := ` +Usage: nomad operator autopilot get-config [options] + + Displays the current Autopilot configuration. + +General Options: + + ` + generalOptionsUsage() + + return strings.TrimSpace(helpText) +} diff --git a/command/operator_autopilot_get_test.go b/command/operator_autopilot_get_test.go new file mode 100644 index 000000000..f8e4a9dc5 --- /dev/null +++ b/command/operator_autopilot_get_test.go @@ -0,0 +1,32 @@ +package command + +import ( + "strings" + "testing" + + "github.com/mitchellh/cli" +) + +func TestOperator_Autopilot_GetConfig_Implements(t *testing.T) { + t.Parallel() + var _ cli.Command = &OperatorRaftListCommand{} +} + +func TestOperatorAutopilotGetConfigCommand(t *testing.T) { + t.Parallel() + s, _, addr := testServer(t, false, nil) + defer s.Shutdown() + + ui := new(cli.MockUi) + c := &OperatorAutopilotGetCommand{Meta: Meta{Ui: ui}} + args := []string{"-address=" + addr} + + code := c.Run(args) + if code != 0 { + t.Fatalf("bad: %d. %#v", code, ui.ErrorWriter.String()) + } + output := strings.TrimSpace(ui.OutputWriter.String()) + if !strings.Contains(output, "CleanupDeadServers = true") { + t.Fatalf("bad: %s", output) + } +} diff --git a/command/operator_autopilot_set.go b/command/operator_autopilot_set.go new file mode 100644 index 000000000..bacefe339 --- /dev/null +++ b/command/operator_autopilot_set.go @@ -0,0 +1,156 @@ +package command + +import ( + "fmt" + "strings" + "time" + + "github.com/hashicorp/consul/command/flags" + "github.com/hashicorp/nomad/api" + "github.com/posener/complete" +) + +type OperatorAutopilotSetCommand struct { + Meta +} + +func (c *OperatorAutopilotSetCommand) AutocompleteFlags() complete.Flags { + return mergeAutocompleteFlags(c.Meta.AutocompleteFlags(FlagSetClient), + complete.Flags{ + "-cleanup-dead-servers": complete.PredictAnything, + "-max-trailing-logs": complete.PredictAnything, + "-last-contact-threshold": complete.PredictAnything, + "-server-stabilization-time": complete.PredictAnything, + "-redundancy-zone-tag": complete.PredictAnything, + "-disable-upgrade-migration": complete.PredictAnything, + "-upgrade-version-tag": complete.PredictAnything, + }) +} + +func (c *OperatorAutopilotSetCommand) AutocompleteArgs() complete.Predictor { + return complete.PredictNothing +} + +func (c *OperatorAutopilotSetCommand) Run(args []string) int { + var cleanupDeadServers flags.BoolValue + var maxTrailingLogs flags.UintValue + var lastContactThreshold flags.DurationValue + var serverStabilizationTime flags.DurationValue + var redundancyZoneTag flags.StringValue + var disableUpgradeMigration flags.BoolValue + var upgradeVersionTag flags.StringValue + + f := c.Meta.FlagSet("autopilot", FlagSetClient) + f.Usage = func() { c.Ui.Output(c.Help()) } + + f.Var(&cleanupDeadServers, "cleanup-dead-servers", "") + f.Var(&maxTrailingLogs, "max-trailing-logs", "") + f.Var(&lastContactThreshold, "last-contact-threshold", "") + f.Var(&serverStabilizationTime, "server-stabilization-time", "") + f.Var(&redundancyZoneTag, "redundancy-zone-tag", "") + f.Var(&disableUpgradeMigration, "disable-upgrade-migration", "") + f.Var(&upgradeVersionTag, "upgrade-version-tag", "") + + if err := f.Parse(args); err != nil { + c.Ui.Error(fmt.Sprintf("Failed to parse args: %v", err)) + return 1 + } + + // Set up a client. + client, err := c.Meta.Client() + if err != nil { + c.Ui.Error(fmt.Sprintf("Error initializing client: %s", err)) + return 1 + } + + // Fetch the current configuration. + operator := client.Operator() + conf, err := operator.AutopilotGetConfiguration(nil) + if err != nil { + c.Ui.Error(fmt.Sprintf("Error querying for Autopilot configuration: %s", err)) + return 1 + } + + // Update the config values based on the set flags. + cleanupDeadServers.Merge(&conf.CleanupDeadServers) + redundancyZoneTag.Merge(&conf.RedundancyZoneTag) + disableUpgradeMigration.Merge(&conf.DisableUpgradeMigration) + upgradeVersionTag.Merge(&conf.UpgradeVersionTag) + + trailing := uint(conf.MaxTrailingLogs) + maxTrailingLogs.Merge(&trailing) + conf.MaxTrailingLogs = uint64(trailing) + + last := time.Duration(*conf.LastContactThreshold) + lastContactThreshold.Merge(&last) + conf.LastContactThreshold = api.NewReadableDuration(last) + + stablization := time.Duration(*conf.ServerStabilizationTime) + serverStabilizationTime.Merge(&stablization) + conf.ServerStabilizationTime = api.NewReadableDuration(stablization) + + // Check-and-set the new configuration. + result, err := operator.AutopilotCASConfiguration(conf, nil) + if err != nil { + c.Ui.Error(fmt.Sprintf("Error setting Autopilot configuration: %s", err)) + return 1 + } + if result { + c.Ui.Output("Configuration updated!") + return 0 + } + c.Ui.Output("Configuration could not be atomically updated, please try again") + return 1 +} + +func (c *OperatorAutopilotSetCommand) Synopsis() string { + return "Modify the current Autopilot configuration" +} + +func (c *OperatorAutopilotSetCommand) Help() string { + helpText := ` +Usage: nomad operator autopilot set-config [options] + + Modifies the current Autopilot configuration. + +General Options: + + ` + generalOptionsUsage() + ` + +Set Config Options: + + -cleanup-dead-servers=[true|false] + Controls whether Nomad will automatically remove dead servers when + new ones are successfully added. Must be one of [true|false]. + + -disable-upgrade-migration=[true|false] + (Enterprise-only) Controls whether Nomad will avoid promoting + new servers until it can perform a migration. Must be one of + "true|false". + + -last-contact-threshold=200ms + Controls the maximum amount of time a server can go without contact + from the leader before being considered unhealthy. Must be a + duration value such as "200ms". + + -max-trailing-logs= + Controls the maximum number of log entries that a server can trail + the leader by before being considered unhealthy. + + -redundancy-zone-tag= + (Enterprise-only) Controls the node_meta tag name used for + separating servers into different redundancy zones. + + -server-stabilization-time=<10s> + Controls the minimum amount of time a server must be stable in + the 'healthy' state before being added to the cluster. Only takes + effect if all servers are running Raft protocol version 3 or + higher. Must be a duration value such as "10s". + + -upgrade-version-tag= + (Enterprise-only) The node_meta tag to use for version info when + performing upgrade migrations. If left blank, the Nomad version + will be used. +` + return strings.TrimSpace(helpText) +} diff --git a/command/operator_autopilot_set_test.go b/command/operator_autopilot_set_test.go new file mode 100644 index 000000000..8991ce51e --- /dev/null +++ b/command/operator_autopilot_set_test.go @@ -0,0 +1,62 @@ +package command + +import ( + "strings" + "testing" + "time" + + "github.com/mitchellh/cli" +) + +func TestOperator_Autopilot_SetConfig_Implements(t *testing.T) { + t.Parallel() + var _ cli.Command = &OperatorRaftListCommand{} +} + +func TestOperatorAutopilotSetConfigCommmand(t *testing.T) { + t.Parallel() + s, _, addr := testServer(t, false, nil) + defer s.Shutdown() + + ui := new(cli.MockUi) + c := &OperatorAutopilotSetCommand{Meta: Meta{Ui: ui}} + args := []string{ + "-address=" + addr, + "-cleanup-dead-servers=false", + "-max-trailing-logs=99", + "-last-contact-threshold=123ms", + "-server-stabilization-time=123ms", + } + + code := c.Run(args) + if code != 0 { + t.Fatalf("bad: %d. %#v", code, ui.ErrorWriter.String()) + } + output := strings.TrimSpace(ui.OutputWriter.String()) + if !strings.Contains(output, "Configuration updated") { + t.Fatalf("bad: %s", output) + } + + client, err := c.Client() + if err != nil { + t.Fatal(err) + } + + conf, err := client.Operator().AutopilotGetConfiguration(nil) + if err != nil { + t.Fatal(err) + } + + if conf.CleanupDeadServers { + t.Fatalf("bad: %#v", conf) + } + if conf.MaxTrailingLogs != 99 { + t.Fatalf("bad: %#v", conf) + } + if conf.LastContactThreshold.Duration() != 123*time.Millisecond { + t.Fatalf("bad: %#v", conf) + } + if conf.ServerStabilizationTime.Duration() != 123*time.Millisecond { + t.Fatalf("bad: %#v", conf) + } +} diff --git a/command/operator_autopilot_test.go b/command/operator_autopilot_test.go new file mode 100644 index 000000000..5bff69291 --- /dev/null +++ b/command/operator_autopilot_test.go @@ -0,0 +1,12 @@ +package command + +import ( + "testing" + + "github.com/mitchellh/cli" +) + +func TestOperator_Autopilot_Implements(t *testing.T) { + t.Parallel() + var _ cli.Command = &OperatorAutopilotCommand{} +} diff --git a/command/operator_raft_remove.go b/command/operator_raft_remove.go index 23e22cb47..8a3a2bcbe 100644 --- a/command/operator_raft_remove.go +++ b/command/operator_raft_remove.go @@ -32,7 +32,10 @@ General Options: Remove Peer Options: -peer-address="IP:port" - Remove a Nomad server with given address from the Raft configuration. + Remove a Nomad server with given address from the Raft configuration. + + -peer-id="id" + Remove a Nomad server with the given ID from the Raft configuration. ` return strings.TrimSpace(helpText) } @@ -41,6 +44,7 @@ func (c *OperatorRaftRemoveCommand) AutocompleteFlags() complete.Flags { return mergeAutocompleteFlags(c.Meta.AutocompleteFlags(FlagSetClient), complete.Flags{ "-peer-address": complete.PredictAnything, + "-peer-id": complete.PredictAnything, }) } @@ -54,11 +58,13 @@ func (c *OperatorRaftRemoveCommand) Synopsis() string { func (c *OperatorRaftRemoveCommand) Run(args []string) int { var peerAddress string + var peerID string flags := c.Meta.FlagSet("raft", FlagSetClient) flags.Usage = func() { c.Ui.Output(c.Help()) } flags.StringVar(&peerAddress, "peer-address", "", "") + flags.StringVar(&peerID, "peer-id", "", "") if err := flags.Parse(args); err != nil { c.Ui.Error(fmt.Sprintf("Failed to parse args: %v", err)) return 1 @@ -72,20 +78,37 @@ func (c *OperatorRaftRemoveCommand) Run(args []string) int { } operator := client.Operator() - // TODO (alexdadgar) Once we expose IDs, add support for removing - // by ID, add support for that. - if len(peerAddress) == 0 { - c.Ui.Error(fmt.Sprintf("an address is required for the peer to remove")) + if err := raftRemovePeers(peerAddress, peerID, operator); err != nil { + c.Ui.Error(fmt.Sprintf("Error removing peer: %v", err)) return 1 } - - // Try to kick the peer. - w := &api.WriteOptions{} - if err := operator.RaftRemovePeerByAddress(peerAddress, w); err != nil { - c.Ui.Error(fmt.Sprintf("Failed to remove raft peer: %v", err)) - return 1 + if peerAddress != "" { + c.Ui.Output(fmt.Sprintf("Removed peer with address %q", peerAddress)) + } else { + c.Ui.Output(fmt.Sprintf("Removed peer with id %q", peerID)) } - c.Ui.Output(fmt.Sprintf("Removed peer with address %q", peerAddress)) return 0 } + +func raftRemovePeers(address, id string, operator *api.Operator) error { + if len(address) == 0 && len(id) == 0 { + return fmt.Errorf("an address or id is required for the peer to remove") + } + if len(address) > 0 && len(id) > 0 { + return fmt.Errorf("cannot give both an address and id") + } + + // Try to kick the peer. + if len(address) > 0 { + if err := operator.RaftRemovePeerByAddress(address, nil); err != nil { + return err + } + } else { + if err := operator.RaftRemovePeerByID(id, nil); err != nil { + return err + } + } + + return nil +} diff --git a/command/operator_raft_remove_test.go b/command/operator_raft_remove_test.go index c38e94c86..4d7516e33 100644 --- a/command/operator_raft_remove_test.go +++ b/command/operator_raft_remove_test.go @@ -1,10 +1,10 @@ package command import ( - "strings" "testing" "github.com/mitchellh/cli" + "github.com/stretchr/testify/assert" ) func TestOperator_Raft_RemovePeers_Implements(t *testing.T) { @@ -14,6 +14,35 @@ func TestOperator_Raft_RemovePeers_Implements(t *testing.T) { func TestOperator_Raft_RemovePeer(t *testing.T) { t.Parallel() + assert := assert.New(t) + s, _, addr := testServer(t, false, nil) + defer s.Shutdown() + + ui := new(cli.MockUi) + c := &OperatorRaftRemoveCommand{Meta: Meta{Ui: ui}} + args := []string{"-address=" + addr, "-peer-address=nope", "-peer-id=nope"} + + // Give both an address and ID + code := c.Run(args) + if code != 1 { + t.Fatalf("bad: %d. %#v", code, ui.ErrorWriter.String()) + } + + assert.Contains(ui.ErrorWriter.String(), "cannot give both an address and id") + + // Neither address nor ID present + args = args[:1] + code = c.Run(args) + if code != 1 { + t.Fatalf("bad: %d. %#v", code, ui.ErrorWriter.String()) + } + + assert.Contains(ui.ErrorWriter.String(), "an address or id is required for the peer to remove") +} + +func TestOperator_Raft_RemovePeerAddress(t *testing.T) { + t.Parallel() + assert := assert.New(t) s, _, addr := testServer(t, false, nil) defer s.Shutdown() @@ -27,8 +56,24 @@ func TestOperator_Raft_RemovePeer(t *testing.T) { } // If we get this error, it proves we sent the address all they through. - output := strings.TrimSpace(ui.ErrorWriter.String()) - if !strings.Contains(output, "address \"nope\" was not found in the Raft configuration") { - t.Fatalf("bad: %s", output) - } + assert.Contains(ui.ErrorWriter.String(), "address \"nope\" was not found in the Raft configuration") +} + +func TestOperator_Raft_RemovePeerID(t *testing.T) { + t.Parallel() + assert := assert.New(t) + s, _, addr := testServer(t, false, nil) + defer s.Shutdown() + + ui := new(cli.MockUi) + c := &OperatorRaftRemoveCommand{Meta: Meta{Ui: ui}} + args := []string{"-address=" + addr, "-peer-id=nope"} + + code := c.Run(args) + if code != 1 { + t.Fatalf("bad: %d. %#v", code, ui.ErrorWriter.String()) + } + + // If we get this error, it proves we sent the address all they through. + assert.Contains(ui.ErrorWriter.String(), "id \"nope\" was not found in the Raft configuration") } diff --git a/commands.go b/commands.go index f2f48564b..75155948b 100644 --- a/commands.go +++ b/commands.go @@ -275,6 +275,24 @@ func Commands(metaPtr *command.Meta) map[string]cli.CommandFactory { }, nil }, + "operator autopilot": func() (cli.Command, error) { + return &command.OperatorAutopilotCommand{ + Meta: meta, + }, nil + }, + + "operator autopilot get-config": func() (cli.Command, error) { + return &command.OperatorAutopilotGetCommand{ + Meta: meta, + }, nil + }, + + "operator autopilot set-config": func() (cli.Command, error) { + return &command.OperatorAutopilotSetCommand{ + Meta: meta, + }, nil + }, + "operator raft": func() (cli.Command, error) { return &command.OperatorRaftCommand{ Meta: meta, diff --git a/jobspec/parse.go b/jobspec/parse.go index d25f38bd0..babe41b17 100644 --- a/jobspec/parse.go +++ b/jobspec/parse.go @@ -912,6 +912,7 @@ func parseServices(jobName string, taskGroupName string, task *api.Task, service "port", "check", "address_mode", + "check_restart", } if err := helper.CheckHCLKeys(o.Val, valid); err != nil { return multierror.Prefix(err, fmt.Sprintf("service (%d) ->", idx)) diff --git a/jobspec/parse_test.go b/jobspec/parse_test.go index 2dfc890d4..4134e9ee4 100644 --- a/jobspec/parse_test.go +++ b/jobspec/parse_test.go @@ -631,6 +631,42 @@ func TestParse(t *testing.T) { }, false, }, + { + "service-check-restart.hcl", + &api.Job{ + ID: helper.StringToPtr("service_check_restart"), + Name: helper.StringToPtr("service_check_restart"), + Type: helper.StringToPtr("service"), + TaskGroups: []*api.TaskGroup{ + { + Name: helper.StringToPtr("group"), + Tasks: []*api.Task{ + { + Name: "task", + Services: []*api.Service{ + { + Name: "http-service", + CheckRestart: &api.CheckRestart{ + Limit: 3, + Grace: helper.TimeToPtr(10 * time.Second), + IgnoreWarnings: true, + }, + Checks: []api.ServiceCheck{ + { + Name: "random-check", + Type: "tcp", + PortLabel: "9001", + }, + }, + }, + }, + }, + }, + }, + }, + }, + false, + }, } for _, tc := range cases { diff --git a/jobspec/test-fixtures/service-check-restart.hcl b/jobspec/test-fixtures/service-check-restart.hcl new file mode 100644 index 000000000..d34f70003 --- /dev/null +++ b/jobspec/test-fixtures/service-check-restart.hcl @@ -0,0 +1,21 @@ +job "service_check_restart" { + type = "service" + group "group" { + task "task" { + service { + name = "http-service" + check_restart { + limit = 3 + grace = "10s" + ignore_warnings = true + } + check { + name = "random-check" + type = "tcp" + port = "9001" + } + } + } + } +} + diff --git a/nomad/autopilot.go b/nomad/autopilot.go new file mode 100644 index 000000000..5fd2a9b37 --- /dev/null +++ b/nomad/autopilot.go @@ -0,0 +1,69 @@ +package nomad + +import ( + "context" + "fmt" + + "github.com/armon/go-metrics" + "github.com/hashicorp/consul/agent/consul/autopilot" + "github.com/hashicorp/raft" + "github.com/hashicorp/serf/serf" +) + +// AutopilotDelegate is a Nomad delegate for autopilot operations. +type AutopilotDelegate struct { + server *Server +} + +func (d *AutopilotDelegate) AutopilotConfig() *autopilot.Config { + return d.server.getOrCreateAutopilotConfig() +} + +func (d *AutopilotDelegate) FetchStats(ctx context.Context, servers []serf.Member) map[string]*autopilot.ServerStats { + return d.server.statsFetcher.Fetch(ctx, servers) +} + +func (d *AutopilotDelegate) IsServer(m serf.Member) (*autopilot.ServerInfo, error) { + ok, parts := isNomadServer(m) + if !ok || parts.Region != d.server.Region() { + return nil, nil + } + + server := &autopilot.ServerInfo{ + Name: m.Name, + ID: parts.ID, + Addr: parts.Addr, + Build: parts.Build, + Status: m.Status, + } + return server, nil +} + +// NotifyHealth heartbeats a metric for monitoring if we're the leader. +func (d *AutopilotDelegate) NotifyHealth(health autopilot.OperatorHealthReply) { + if d.server.raft.State() == raft.Leader { + metrics.SetGauge([]string{"nomad", "autopilot", "failure_tolerance"}, float32(health.FailureTolerance)) + if health.Healthy { + metrics.SetGauge([]string{"nomad", "autopilot", "healthy"}, 1) + } else { + metrics.SetGauge([]string{"nomad", "autopilot", "healthy"}, 0) + } + } +} + +func (d *AutopilotDelegate) PromoteNonVoters(conf *autopilot.Config, health autopilot.OperatorHealthReply) ([]raft.Server, error) { + future := d.server.raft.GetConfiguration() + if err := future.Error(); err != nil { + return nil, fmt.Errorf("failed to get raft configuration: %v", err) + } + + return autopilot.PromoteStableServers(conf, health, future.Configuration().Servers), nil +} + +func (d *AutopilotDelegate) Raft() *raft.Raft { + return d.server.raft +} + +func (d *AutopilotDelegate) Serf() *serf.Serf { + return d.server.serf +} diff --git a/nomad/autopilot_test.go b/nomad/autopilot_test.go new file mode 100644 index 000000000..6511c8be9 --- /dev/null +++ b/nomad/autopilot_test.go @@ -0,0 +1,350 @@ +package nomad + +import ( + "testing" + "time" + + "fmt" + + "github.com/hashicorp/consul/agent/consul/autopilot" + "github.com/hashicorp/consul/testutil/retry" + "github.com/hashicorp/nomad/testutil" + "github.com/hashicorp/raft" + "github.com/hashicorp/serf/serf" +) + +// wantPeers determines whether the server has the given +// number of voting raft peers. +func wantPeers(s *Server, peers int) error { + future := s.raft.GetConfiguration() + if err := future.Error(); err != nil { + return err + } + + n := autopilot.NumPeers(future.Configuration()) + if got, want := n, peers; got != want { + return fmt.Errorf("got %d peers want %d", got, want) + } + return nil +} + +// wantRaft determines if the servers have all of each other in their +// Raft configurations, +func wantRaft(servers []*Server) error { + // Make sure all the servers are represented in the Raft config, + // and that there are no extras. + verifyRaft := func(c raft.Configuration) error { + want := make(map[raft.ServerID]bool) + for _, s := range servers { + want[s.config.RaftConfig.LocalID] = true + } + + for _, s := range c.Servers { + if !want[s.ID] { + return fmt.Errorf("don't want %q", s.ID) + } + delete(want, s.ID) + } + + if len(want) > 0 { + return fmt.Errorf("didn't find %v", want) + } + return nil + } + + for _, s := range servers { + future := s.raft.GetConfiguration() + if err := future.Error(); err != nil { + return err + } + if err := verifyRaft(future.Configuration()); err != nil { + return err + } + } + return nil +} + +func TestAutopilot_CleanupDeadServer(t *testing.T) { + t.Parallel() + for i := 1; i <= 3; i++ { + testCleanupDeadServer(t, i) + } +} + +func testCleanupDeadServer(t *testing.T, raftVersion int) { + conf := func(c *Config) { + c.DevDisableBootstrap = true + c.BootstrapExpect = 3 + c.RaftConfig.ProtocolVersion = raft.ProtocolVersion(raftVersion) + } + s1 := testServer(t, conf) + defer s1.Shutdown() + + s2 := testServer(t, conf) + defer s2.Shutdown() + + s3 := testServer(t, conf) + defer s3.Shutdown() + + servers := []*Server{s1, s2, s3} + + // Try to join + testJoin(t, s1, s2, s3) + + for _, s := range servers { + retry.Run(t, func(r *retry.R) { r.Check(wantPeers(s, 3)) }) + } + + // Bring up a new server + s4 := testServer(t, conf) + defer s4.Shutdown() + + // Kill a non-leader server + s3.Shutdown() + retry.Run(t, func(r *retry.R) { + alive := 0 + for _, m := range s1.Members() { + if m.Status == serf.StatusAlive { + alive++ + } + } + if alive != 2 { + r.Fatal(nil) + } + }) + + // Join the new server + testJoin(t, s1, s4) + servers[2] = s4 + + // Make sure the dead server is removed and we're back to 3 total peers + for _, s := range servers { + retry.Run(t, func(r *retry.R) { r.Check(wantPeers(s, 3)) }) + } +} + +func TestAutopilot_CleanupDeadServerPeriodic(t *testing.T) { + t.Parallel() + s1 := testServer(t, nil) + defer s1.Shutdown() + + conf := func(c *Config) { + c.DevDisableBootstrap = true + } + + s2 := testServer(t, conf) + defer s2.Shutdown() + + s3 := testServer(t, conf) + defer s3.Shutdown() + + s4 := testServer(t, conf) + defer s4.Shutdown() + + s5 := testServer(t, conf) + defer s5.Shutdown() + + servers := []*Server{s1, s2, s3, s4, s5} + + // Join the servers to s1, and wait until they are all promoted to + // voters. + testJoin(t, s1, servers[1:]...) + retry.Run(t, func(r *retry.R) { + r.Check(wantRaft(servers)) + for _, s := range servers { + r.Check(wantPeers(s, 5)) + } + }) + + // Kill a non-leader server + s4.Shutdown() + + // Should be removed from the peers automatically + servers = []*Server{s1, s2, s3, s5} + retry.Run(t, func(r *retry.R) { + r.Check(wantRaft(servers)) + for _, s := range servers { + r.Check(wantPeers(s, 4)) + } + }) +} + +func TestAutopilot_RollingUpdate(t *testing.T) { + t.Parallel() + s1 := testServer(t, func(c *Config) { + c.RaftConfig.ProtocolVersion = 3 + }) + defer s1.Shutdown() + + conf := func(c *Config) { + c.DevDisableBootstrap = true + c.RaftConfig.ProtocolVersion = 3 + } + + s2 := testServer(t, conf) + defer s2.Shutdown() + + s3 := testServer(t, conf) + defer s3.Shutdown() + + // Join the servers to s1, and wait until they are all promoted to + // voters. + servers := []*Server{s1, s2, s3} + testJoin(t, s1, s2, s3) + retry.Run(t, func(r *retry.R) { + r.Check(wantRaft(servers)) + for _, s := range servers { + r.Check(wantPeers(s, 3)) + } + }) + + // Add one more server like we are doing a rolling update. + s4 := testServer(t, conf) + defer s4.Shutdown() + testJoin(t, s1, s4) + servers = append(servers, s4) + retry.Run(t, func(r *retry.R) { + r.Check(wantRaft(servers)) + for _, s := range servers { + r.Check(wantPeers(s, 3)) + } + }) + + // Now kill one of the "old" nodes like we are doing a rolling update. + s3.Shutdown() + + isVoter := func() bool { + future := s1.raft.GetConfiguration() + if err := future.Error(); err != nil { + t.Fatalf("err: %v", err) + } + for _, s := range future.Configuration().Servers { + if string(s.ID) == string(s4.config.NodeID) { + return s.Suffrage == raft.Voter + } + } + t.Fatalf("didn't find s4") + return false + } + + // Wait for s4 to stabilize, get promoted to a voter, and for s3 to be + // removed. + servers = []*Server{s1, s2, s4} + retry.Run(t, func(r *retry.R) { + r.Check(wantRaft(servers)) + for _, s := range servers { + r.Check(wantPeers(s, 3)) + } + if !isVoter() { + r.Fatalf("should be a voter") + } + }) +} + +func TestAutopilot_CleanupStaleRaftServer(t *testing.T) { + t.Parallel() + s1 := testServer(t, nil) + defer s1.Shutdown() + + conf := func(c *Config) { + c.DevDisableBootstrap = true + } + s2 := testServer(t, conf) + defer s2.Shutdown() + + s3 := testServer(t, conf) + defer s3.Shutdown() + + s4 := testServer(t, conf) + defer s4.Shutdown() + + servers := []*Server{s1, s2, s3} + + // Join the servers to s1 + testJoin(t, s1, s2, s3) + + for _, s := range servers { + retry.Run(t, func(r *retry.R) { r.Check(wantPeers(s, 3)) }) + } + + testutil.WaitForLeader(t, s1.RPC) + + // Add s4 to peers directly + addr := fmt.Sprintf("127.0.0.1:%d", s4.config.SerfConfig.MemberlistConfig.BindPort) + s1.raft.AddVoter(raft.ServerID(s4.config.NodeID), raft.ServerAddress(addr), 0, 0) + + // Verify we have 4 peers + peers, err := s1.numPeers() + if err != nil { + t.Fatal(err) + } + if peers != 4 { + t.Fatalf("bad: %v", peers) + } + + // Wait for s4 to be removed + for _, s := range []*Server{s1, s2, s3} { + retry.Run(t, func(r *retry.R) { r.Check(wantPeers(s, 3)) }) + } +} + +func TestAutopilot_PromoteNonVoter(t *testing.T) { + t.Parallel() + s1 := testServer(t, func(c *Config) { + c.RaftConfig.ProtocolVersion = 3 + }) + defer s1.Shutdown() + codec := rpcClient(t, s1) + defer codec.Close() + testutil.WaitForLeader(t, s1.RPC) + + s2 := testServer(t, func(c *Config) { + c.DevDisableBootstrap = true + c.RaftConfig.ProtocolVersion = 3 + }) + defer s2.Shutdown() + testJoin(t, s1, s2) + + // Make sure we see it as a nonvoter initially. We wait until half + // the stabilization period has passed. + retry.Run(t, func(r *retry.R) { + future := s1.raft.GetConfiguration() + if err := future.Error(); err != nil { + r.Fatal(err) + } + + servers := future.Configuration().Servers + if len(servers) != 2 { + r.Fatalf("bad: %v", servers) + } + if servers[1].Suffrage != raft.Nonvoter { + r.Fatalf("bad: %v", servers) + } + health := s1.autopilot.GetServerHealth(string(servers[1].ID)) + if health == nil { + r.Fatalf("nil health, %v", s1.autopilot.GetClusterHealth()) + } + if !health.Healthy { + r.Fatalf("bad: %v", health) + } + if time.Since(health.StableSince) < s1.config.AutopilotConfig.ServerStabilizationTime/2 { + r.Fatal("stable period not elapsed") + } + }) + + // Make sure it ends up as a voter. + retry.Run(t, func(r *retry.R) { + future := s1.raft.GetConfiguration() + if err := future.Error(); err != nil { + r.Fatal(err) + } + + servers := future.Configuration().Servers + if len(servers) != 2 { + r.Fatalf("bad: %v", servers) + } + if servers[1].Suffrage != raft.Voter { + r.Fatalf("bad: %v", servers) + } + }) +} diff --git a/nomad/config.go b/nomad/config.go index 4a918f393..5fd8dad8e 100644 --- a/nomad/config.go +++ b/nomad/config.go @@ -8,6 +8,7 @@ import ( "runtime" "time" + "github.com/hashicorp/consul/agent/consul/autopilot" "github.com/hashicorp/memberlist" "github.com/hashicorp/nomad/helper/tlsutil" "github.com/hashicorp/nomad/helper/uuid" @@ -93,6 +94,10 @@ type Config struct { // RaftTimeout is applied to any network traffic for raft. Defaults to 10s. RaftTimeout time.Duration + // (Enterprise-only) NonVoter is used to prevent this server from being added + // as a voting member of the Raft cluster. + NonVoter bool + // SerfConfig is the configuration for the serf cluster SerfConfig *serf.Config @@ -261,6 +266,19 @@ type Config struct { // BackwardsCompatibleMetrics determines whether to show methods of // displaying metrics for older verions, or to only show the new format BackwardsCompatibleMetrics bool + + // AutopilotConfig is used to apply the initial autopilot config when + // bootstrapping. + AutopilotConfig *autopilot.Config + + // ServerHealthInterval is the frequency with which the health of the + // servers in the cluster will be updated. + ServerHealthInterval time.Duration + + // AutopilotInterval is the frequency with which the leader will perform + // autopilot tasks, such as promoting eligible non-voters and removing + // dead servers. + AutopilotInterval time.Duration } // CheckVersion is used to check if the ProtocolVersion is valid @@ -321,6 +339,14 @@ func DefaultConfig() *Config { TLSConfig: &config.TLSConfig{}, ReplicationBackoff: 30 * time.Second, SentinelGCInterval: 30 * time.Second, + AutopilotConfig: &autopilot.Config{ + CleanupDeadServers: true, + LastContactThreshold: 200 * time.Millisecond, + MaxTrailingLogs: 250, + ServerStabilizationTime: 10 * time.Second, + }, + ServerHealthInterval: 2 * time.Second, + AutopilotInterval: 10 * time.Second, } // Enable all known schedulers by default @@ -344,8 +370,8 @@ func DefaultConfig() *Config { // Disable shutdown on removal c.RaftConfig.ShutdownOnRemove = false - // Enable interoperability with raft protocol version 1, and don't - // start using new ID-based features yet. + // Enable interoperability with new raft APIs, requires all servers + // to be on raft v1 or higher. c.RaftConfig.ProtocolVersion = 2 return c diff --git a/nomad/fsm.go b/nomad/fsm.go index 0a004c836..61c14bfe4 100644 --- a/nomad/fsm.go +++ b/nomad/fsm.go @@ -234,6 +234,8 @@ func (n *nomadFSM) Apply(log *raft.Log) interface{} { return n.applyACLTokenDelete(buf[1:], log.Index) case structs.ACLTokenBootstrapRequestType: return n.applyACLTokenBootstrap(buf[1:], log.Index) + case structs.AutopilotRequestType: + return n.applyAutopilotUpdate(buf[1:], log.Index) } // Check enterprise only message types. @@ -833,6 +835,23 @@ func (n *nomadFSM) applyACLTokenBootstrap(buf []byte, index uint64) interface{} return nil } +func (n *nomadFSM) applyAutopilotUpdate(buf []byte, index uint64) interface{} { + var req structs.AutopilotSetConfigRequest + if err := structs.Decode(buf, &req); err != nil { + panic(fmt.Errorf("failed to decode request: %v", err)) + } + defer metrics.MeasureSince([]string{"nomad", "fsm", "autopilot"}, time.Now()) + + if req.CAS { + act, err := n.state.AutopilotCASConfig(index, req.Config.ModifyIndex, &req.Config) + if err != nil { + return err + } + return act + } + return n.state.AutopilotSetConfig(index, &req.Config) +} + func (n *nomadFSM) Snapshot() (raft.FSMSnapshot, error) { // Create a new snapshot snap, err := n.state.Snapshot() diff --git a/nomad/fsm_test.go b/nomad/fsm_test.go index fdaf681b7..90c0b6c12 100644 --- a/nomad/fsm_test.go +++ b/nomad/fsm_test.go @@ -10,6 +10,7 @@ import ( "time" "github.com/google/go-cmp/cmp" + "github.com/hashicorp/consul/agent/consul/autopilot" memdb "github.com/hashicorp/go-memdb" "github.com/hashicorp/nomad/helper" "github.com/hashicorp/nomad/nomad/mock" @@ -2310,3 +2311,62 @@ func TestFSM_ReconcileSummaries(t *testing.T) { t.Fatalf("Diff % #v", pretty.Diff(&expected, out2)) } } + +func TestFSM_Autopilot(t *testing.T) { + t.Parallel() + fsm := testFSM(t) + + // Set the autopilot config using a request. + req := structs.AutopilotSetConfigRequest{ + Datacenter: "dc1", + Config: autopilot.Config{ + CleanupDeadServers: true, + LastContactThreshold: 10 * time.Second, + MaxTrailingLogs: 300, + }, + } + buf, err := structs.Encode(structs.AutopilotRequestType, req) + if err != nil { + t.Fatalf("err: %v", err) + } + resp := fsm.Apply(makeLog(buf)) + if _, ok := resp.(error); ok { + t.Fatalf("bad: %v", resp) + } + + // Verify key is set directly in the state store. + _, config, err := fsm.state.AutopilotConfig() + if err != nil { + t.Fatalf("err: %v", err) + } + if config.CleanupDeadServers != req.Config.CleanupDeadServers { + t.Fatalf("bad: %v", config.CleanupDeadServers) + } + if config.LastContactThreshold != req.Config.LastContactThreshold { + t.Fatalf("bad: %v", config.LastContactThreshold) + } + if config.MaxTrailingLogs != req.Config.MaxTrailingLogs { + t.Fatalf("bad: %v", config.MaxTrailingLogs) + } + + // Now use CAS and provide an old index + req.CAS = true + req.Config.CleanupDeadServers = false + req.Config.ModifyIndex = config.ModifyIndex - 1 + buf, err = structs.Encode(structs.AutopilotRequestType, req) + if err != nil { + t.Fatalf("err: %v", err) + } + resp = fsm.Apply(makeLog(buf)) + if _, ok := resp.(error); ok { + t.Fatalf("bad: %v", resp) + } + + _, config, err = fsm.state.AutopilotConfig() + if err != nil { + t.Fatalf("err: %v", err) + } + if !config.CleanupDeadServers { + t.Fatalf("bad: %v", config.CleanupDeadServers) + } +} diff --git a/nomad/leader.go b/nomad/leader.go index b7be18c06..543d28ba9 100644 --- a/nomad/leader.go +++ b/nomad/leader.go @@ -13,7 +13,9 @@ import ( "golang.org/x/time/rate" "github.com/armon/go-metrics" + "github.com/hashicorp/consul/agent/consul/autopilot" memdb "github.com/hashicorp/go-memdb" + "github.com/hashicorp/go-version" "github.com/hashicorp/nomad/helper/uuid" "github.com/hashicorp/nomad/nomad/state" "github.com/hashicorp/nomad/nomad/structs" @@ -37,6 +39,8 @@ const ( barrierWriteTimeout = 2 * time.Minute ) +var minAutopilotVersion = version.Must(version.NewVersion("0.8.0")) + // monitorLeadership is used to monitor if we acquire or lose our role // as the leader in the Raft cluster. There is some work the leader is // expected to do, so we must react to changes @@ -168,6 +172,10 @@ func (s *Server) establishLeadership(stopCh chan struct{}) error { } } + // Initialize and start the autopilot routine + s.getOrCreateAutopilotConfig() + s.autopilot.Start() + // Enable the plan queue, since we are now the leader s.planQueue.SetEnabled(true) @@ -635,6 +643,9 @@ func (s *Server) revokeLeadership() error { // Clear the leader token since we are no longer the leader. s.setLeaderAcl("") + // Disable autopilot + s.autopilot.Stop() + // Disable the plan queue, since we are no longer leader s.planQueue.SetEnabled(false) @@ -776,7 +787,7 @@ func (s *Server) addRaftPeer(m serf.Member, parts *serverParts) error { // but we want to avoid doing that if possible to prevent useless Raft // log entries. If the address is the same but the ID changed, remove the // old server before adding the new one. - minRaftProtocol, err := MinRaftProtocol(s.config.Region, members) + minRaftProtocol, err := s.autopilot.MinRaftProtocol() if err != nil { return err } @@ -810,8 +821,7 @@ func (s *Server) addRaftPeer(m serf.Member, parts *serverParts) error { // Attempt to add as a peer switch { case minRaftProtocol >= 3: - // todo(kyhavlov): change this to AddNonVoter when adding autopilot - addFuture := s.raft.AddVoter(raft.ServerID(parts.ID), raft.ServerAddress(addr), 0, 0) + addFuture := s.raft.AddNonvoter(raft.ServerID(parts.ID), raft.ServerAddress(addr), 0, 0) if err := addFuture.Error(); err != nil { s.logger.Printf("[ERR] nomad: failed to add raft peer: %v", err) return err @@ -836,7 +846,6 @@ func (s *Server) addRaftPeer(m serf.Member, parts *serverParts) error { // removeRaftPeer is used to remove a Raft peer when a Nomad server leaves // or is reaped func (s *Server) removeRaftPeer(m serf.Member, parts *serverParts) error { - // TODO (alexdadgar) - This will need to be changed once we support node IDs. addr := (&net.TCPAddr{IP: m.Addr, Port: parts.Port}).String() // See if it's already in the configuration. It's harmless to re-remove it @@ -848,7 +857,7 @@ func (s *Server) removeRaftPeer(m serf.Member, parts *serverParts) error { return err } - minRaftProtocol, err := MinRaftProtocol(s.config.Region, s.serf.Members()) + minRaftProtocol, err := s.autopilot.MinRaftProtocol() if err != nil { return err } @@ -1163,3 +1172,31 @@ func diffACLTokens(state *state.StateStore, minIndex uint64, remoteList []*struc } return } + +// getOrCreateAutopilotConfig is used to get the autopilot config, initializing it if necessary +func (s *Server) getOrCreateAutopilotConfig() *autopilot.Config { + state := s.fsm.State() + _, config, err := state.AutopilotConfig() + if err != nil { + s.logger.Printf("[ERR] autopilot: failed to get config: %v", err) + return nil + } + if config != nil { + return config + } + + if !ServersMeetMinimumVersion(s.Members(), minAutopilotVersion) { + s.logger.Printf("[INFO] autopilot: version %v", s.Members()[0].Tags) + s.logger.Printf("[WARN] autopilot: can't initialize until all servers are >= %s", minAutopilotVersion.String()) + return nil + } + + config = s.config.AutopilotConfig + req := structs.AutopilotSetConfigRequest{Config: *config} + if _, _, err = s.raftApply(structs.AutopilotRequestType, req); err != nil { + s.logger.Printf("[ERR] autopilot: failed to initialize config: %v", err) + return nil + } + + return config +} diff --git a/nomad/leader_test.go b/nomad/leader_test.go index 765f2638f..4689cbfcb 100644 --- a/nomad/leader_test.go +++ b/nomad/leader_test.go @@ -6,6 +6,7 @@ import ( "testing" "time" + "github.com/hashicorp/consul/testutil/retry" memdb "github.com/hashicorp/go-memdb" "github.com/hashicorp/nomad/nomad/mock" "github.com/hashicorp/nomad/nomad/state" @@ -815,21 +816,18 @@ func TestLeader_DiffACLTokens(t *testing.T) { func TestLeader_UpgradeRaftVersion(t *testing.T) { t.Parallel() s1 := testServer(t, func(c *Config) { - c.Datacenter = "dc1" c.RaftConfig.ProtocolVersion = 2 }) defer s1.Shutdown() s2 := testServer(t, func(c *Config) { c.DevDisableBootstrap = true - c.Datacenter = "dc1" c.RaftConfig.ProtocolVersion = 1 }) defer s2.Shutdown() s3 := testServer(t, func(c *Config) { c.DevDisableBootstrap = true - c.Datacenter = "dc1" c.RaftConfig.ProtocolVersion = 2 }) defer s3.Shutdown() @@ -854,7 +852,7 @@ func TestLeader_UpgradeRaftVersion(t *testing.T) { } for _, s := range []*Server{s1, s3} { - minVer, err := MinRaftProtocol(s1.config.Region, s.Members()) + minVer, err := s.autopilot.MinRaftProtocol() if err != nil { t.Fatal(err) } @@ -902,3 +900,81 @@ func TestLeader_UpgradeRaftVersion(t *testing.T) { }) } } + +func TestLeader_RollRaftServer(t *testing.T) { + t.Parallel() + s1 := testServer(t, func(c *Config) { + c.RaftConfig.ProtocolVersion = 2 + }) + defer s1.Shutdown() + + s2 := testServer(t, func(c *Config) { + c.DevDisableBootstrap = true + c.RaftConfig.ProtocolVersion = 1 + }) + defer s2.Shutdown() + + s3 := testServer(t, func(c *Config) { + c.DevDisableBootstrap = true + c.RaftConfig.ProtocolVersion = 2 + }) + defer s3.Shutdown() + + servers := []*Server{s1, s2, s3} + + // Try to join + testJoin(t, s1, s2, s3) + + for _, s := range servers { + retry.Run(t, func(r *retry.R) { r.Check(wantPeers(s, 3)) }) + } + + // Kill the v1 server + s2.Shutdown() + + for _, s := range []*Server{s1, s3} { + retry.Run(t, func(r *retry.R) { + minVer, err := s.autopilot.MinRaftProtocol() + if err != nil { + r.Fatal(err) + } + if got, want := minVer, 2; got != want { + r.Fatalf("got min raft version %d want %d", got, want) + } + }) + } + + // Replace the dead server with one running raft protocol v3 + s4 := testServer(t, func(c *Config) { + c.DevDisableBootstrap = true + c.RaftConfig.ProtocolVersion = 3 + }) + defer s4.Shutdown() + testJoin(t, s4, s1) + servers[1] = s4 + + // Make sure the dead server is removed and we're back to 3 total peers + for _, s := range servers { + retry.Run(t, func(r *retry.R) { + addrs := 0 + ids := 0 + future := s.raft.GetConfiguration() + if err := future.Error(); err != nil { + r.Fatal(err) + } + for _, server := range future.Configuration().Servers { + if string(server.ID) == string(server.Address) { + addrs++ + } else { + ids++ + } + } + if got, want := addrs, 2; got != want { + r.Fatalf("got %d server addresses want %d", got, want) + } + if got, want := ids, 1; got != want { + r.Fatalf("got %d server ids want %d", got, want) + } + }) + } +} diff --git a/nomad/operator_endpoint.go b/nomad/operator_endpoint.go index 0edfc4b71..b0a54d700 100644 --- a/nomad/operator_endpoint.go +++ b/nomad/operator_endpoint.go @@ -4,6 +4,7 @@ import ( "fmt" "net" + "github.com/hashicorp/consul/agent/consul/autopilot" "github.com/hashicorp/nomad/nomad/structs" "github.com/hashicorp/raft" "github.com/hashicorp/serf/serf" @@ -124,3 +125,161 @@ REMOVE: op.srv.logger.Printf("[WARN] nomad.operator: Removed Raft peer %q", args.Address) return nil } + +// RaftRemovePeerByID is used to kick a stale peer (one that is in the Raft +// quorum but no longer known to Serf or the catalog) by address in the form of +// "IP:port". The reply argument is not used, but is required to fulfill the RPC +// interface. +func (op *Operator) RaftRemovePeerByID(args *structs.RaftPeerByIDRequest, reply *struct{}) error { + if done, err := op.srv.forward("Operator.RaftRemovePeerByID", args, args, reply); done { + return err + } + + // Check management permissions + if aclObj, err := op.srv.ResolveToken(args.AuthToken); err != nil { + return err + } else if aclObj != nil && !aclObj.IsManagement() { + return structs.ErrPermissionDenied + } + + // Since this is an operation designed for humans to use, we will return + // an error if the supplied id isn't among the peers since it's + // likely they screwed up. + var address raft.ServerAddress + { + future := op.srv.raft.GetConfiguration() + if err := future.Error(); err != nil { + return err + } + for _, s := range future.Configuration().Servers { + if s.ID == args.ID { + address = s.Address + goto REMOVE + } + } + return fmt.Errorf("id %q was not found in the Raft configuration", + args.ID) + } + +REMOVE: + // The Raft library itself will prevent various forms of foot-shooting, + // like making a configuration with no voters. Some consideration was + // given here to adding more checks, but it was decided to make this as + // low-level and direct as possible. We've got ACL coverage to lock this + // down, and if you are an operator, it's assumed you know what you are + // doing if you are calling this. If you remove a peer that's known to + // Serf, for example, it will come back when the leader does a reconcile + // pass. + minRaftProtocol, err := op.srv.autopilot.MinRaftProtocol() + if err != nil { + return err + } + + var future raft.Future + if minRaftProtocol >= 2 { + future = op.srv.raft.RemoveServer(args.ID, 0, 0) + } else { + future = op.srv.raft.RemovePeer(address) + } + if err := future.Error(); err != nil { + op.srv.logger.Printf("[WARN] nomad.operator: Failed to remove Raft peer with id %q: %v", + args.ID, err) + return err + } + + op.srv.logger.Printf("[WARN] nomad.operator: Removed Raft peer with id %q", args.ID) + return nil +} + +// AutopilotGetConfiguration is used to retrieve the current Autopilot configuration. +func (op *Operator) AutopilotGetConfiguration(args *structs.GenericRequest, reply *autopilot.Config) error { + if done, err := op.srv.forward("Operator.AutopilotGetConfiguration", args, args, reply); done { + return err + } + + // This action requires operator read access. + rule, err := op.srv.ResolveToken(args.AuthToken) + if err != nil { + return err + } + if rule != nil && !rule.AllowOperatorRead() { + return structs.ErrPermissionDenied + } + + state := op.srv.fsm.State() + _, config, err := state.AutopilotConfig() + if err != nil { + return err + } + if config == nil { + return fmt.Errorf("autopilot config not initialized yet") + } + + *reply = *config + + return nil +} + +// AutopilotSetConfiguration is used to set the current Autopilot configuration. +func (op *Operator) AutopilotSetConfiguration(args *structs.AutopilotSetConfigRequest, reply *bool) error { + if done, err := op.srv.forward("Operator.AutopilotSetConfiguration", args, args, reply); done { + return err + } + + // This action requires operator write access. + rule, err := op.srv.ResolveToken(args.AuthToken) + if err != nil { + return err + } + if rule != nil && !rule.AllowOperatorWrite() { + return structs.ErrPermissionDenied + } + + // Apply the update + resp, _, err := op.srv.raftApply(structs.AutopilotRequestType, args) + if err != nil { + op.srv.logger.Printf("[ERR] nomad.operator: Apply failed: %v", err) + return err + } + if respErr, ok := resp.(error); ok { + return respErr + } + + // Check if the return type is a bool. + if respBool, ok := resp.(bool); ok { + *reply = respBool + } + return nil +} + +// ServerHealth is used to get the current health of the servers. +func (op *Operator) ServerHealth(args *structs.GenericRequest, reply *autopilot.OperatorHealthReply) error { + // This must be sent to the leader, so we fix the args since we are + // re-using a structure where we don't support all the options. + args.AllowStale = false + if done, err := op.srv.forward("Operator.ServerHealth", args, args, reply); done { + return err + } + + // This action requires operator read access. + rule, err := op.srv.ResolveToken(args.AuthToken) + if err != nil { + return err + } + if rule != nil && !rule.AllowOperatorRead() { + return structs.ErrPermissionDenied + } + + // Exit early if the min Raft version is too low + minRaftProtocol, err := op.srv.autopilot.MinRaftProtocol() + if err != nil { + return fmt.Errorf("error getting server raft protocol versions: %s", err) + } + if minRaftProtocol < 3 { + return fmt.Errorf("all servers must have raft_protocol set to 3 or higher to use this endpoint") + } + + *reply = op.srv.autopilot.GetClusterHealth() + + return nil +} diff --git a/nomad/operator_endpoint_test.go b/nomad/operator_endpoint_test.go index 9c211fb0e..64115d01a 100644 --- a/nomad/operator_endpoint_test.go +++ b/nomad/operator_endpoint_test.go @@ -225,3 +225,111 @@ func TestOperator_RaftRemovePeerByAddress_ACL(t *testing.T) { assert.Nil(err) } } + +func TestOperator_RaftRemovePeerByID(t *testing.T) { + t.Parallel() + s1 := testServer(t, func(c *Config) { + c.RaftConfig.ProtocolVersion = 3 + }) + defer s1.Shutdown() + codec := rpcClient(t, s1) + testutil.WaitForLeader(t, s1.RPC) + + // Try to remove a peer that's not there. + arg := structs.RaftPeerByIDRequest{ + ID: raft.ServerID("e35bde83-4e9c-434f-a6ef-453f44ee21ea"), + } + arg.Region = s1.config.Region + var reply struct{} + err := msgpackrpc.CallWithCodec(codec, "Operator.RaftRemovePeerByID", &arg, &reply) + if err == nil || !strings.Contains(err.Error(), "not found in the Raft configuration") { + t.Fatalf("err: %v", err) + } + + // Add it manually to Raft. + { + future := s1.raft.AddVoter(arg.ID, raft.ServerAddress(fmt.Sprintf("127.0.0.1:%d", freeport.GetT(t, 1)[0])), 0, 0) + if err := future.Error(); err != nil { + t.Fatalf("err: %v", err) + } + } + + // Make sure it's there. + { + future := s1.raft.GetConfiguration() + if err := future.Error(); err != nil { + t.Fatalf("err: %v", err) + } + configuration := future.Configuration() + if len(configuration.Servers) != 2 { + t.Fatalf("bad: %v", configuration) + } + } + + // Remove it, now it should go through. + if err := msgpackrpc.CallWithCodec(codec, "Operator.RaftRemovePeerByID", &arg, &reply); err != nil { + t.Fatalf("err: %v", err) + } + + // Make sure it's not there. + { + future := s1.raft.GetConfiguration() + if err := future.Error(); err != nil { + t.Fatalf("err: %v", err) + } + configuration := future.Configuration() + if len(configuration.Servers) != 1 { + t.Fatalf("bad: %v", configuration) + } + } +} + +func TestOperator_RaftRemovePeerByID_ACL(t *testing.T) { + t.Parallel() + s1, root := testACLServer(t, func(c *Config) { + c.RaftConfig.ProtocolVersion = 3 + }) + defer s1.Shutdown() + codec := rpcClient(t, s1) + testutil.WaitForLeader(t, s1.RPC) + assert := assert.New(t) + state := s1.fsm.State() + + // Create ACL token + invalidToken := mock.CreatePolicyAndToken(t, state, 1001, "test-invalid", mock.NodePolicy(acl.PolicyWrite)) + + arg := structs.RaftPeerByIDRequest{ + ID: raft.ServerID("e35bde83-4e9c-434f-a6ef-453f44ee21ea"), + } + arg.Region = s1.config.Region + + // Add peer manually to Raft. + { + future := s1.raft.AddVoter(arg.ID, raft.ServerAddress(fmt.Sprintf("127.0.0.1:%d", freeport.GetT(t, 1)[0])), 0, 0) + assert.Nil(future.Error()) + } + + var reply struct{} + + // Try with no token and expect permission denied + { + err := msgpackrpc.CallWithCodec(codec, "Operator.RaftRemovePeerByID", &arg, &reply) + assert.NotNil(err) + assert.Equal(err.Error(), structs.ErrPermissionDenied.Error()) + } + + // Try with an invalid token and expect permission denied + { + arg.AuthToken = invalidToken.SecretID + err := msgpackrpc.CallWithCodec(codec, "Operator.RaftRemovePeerByID", &arg, &reply) + assert.NotNil(err) + assert.Equal(err.Error(), structs.ErrPermissionDenied.Error()) + } + + // Try with a management token + { + arg.AuthToken = root.SecretID + err := msgpackrpc.CallWithCodec(codec, "Operator.RaftRemovePeerByID", &arg, &reply) + assert.Nil(err) + } +} diff --git a/nomad/serf.go b/nomad/serf.go index b5df4646a..2bfc11ba1 100644 --- a/nomad/serf.go +++ b/nomad/serf.go @@ -184,7 +184,7 @@ func (s *Server) maybeBootstrap() { // Attempt a live bootstrap! var configuration raft.Configuration var addrs []string - minRaftVersion, err := MinRaftProtocol(s.config.Region, members) + minRaftVersion, err := s.autopilot.MinRaftProtocol() if err != nil { s.logger.Printf("[ERR] nomad: Failed to read server raft versions: %v", err) } diff --git a/nomad/server.go b/nomad/server.go index 7648c7436..ba9c2bd1a 100644 --- a/nomad/server.go +++ b/nomad/server.go @@ -17,6 +17,7 @@ import ( "sync/atomic" "time" + "github.com/hashicorp/consul/agent/consul/autopilot" consulapi "github.com/hashicorp/consul/api" "github.com/hashicorp/consul/lib" multierror "github.com/hashicorp/go-multierror" @@ -100,6 +101,9 @@ type Server struct { raftInmem *raft.InmemStore raftTransport *raft.NetworkTransport + // autopilot is the Autopilot instance for this server. + autopilot *autopilot.Autopilot + // fsm is the state machine used with Raft fsm *nomadFSM @@ -171,6 +175,10 @@ type Server struct { leaderAcl string leaderAclLock sync.Mutex + // statsFetcher is used by autopilot to check the status of the other + // Nomad router. + statsFetcher *StatsFetcher + // EnterpriseState is used to fill in state for Pro/Ent builds EnterpriseState @@ -271,6 +279,9 @@ func NewServer(config *Config, consulCatalog consul.CatalogAPI, logger *log.Logg // Create the periodic dispatcher for launching periodic jobs. s.periodicDispatcher = NewPeriodicDispatch(s.logger, s) + // Initialize the stats fetcher that autopilot will use. + s.statsFetcher = NewStatsFetcher(logger, s.connPool, s.config.Region) + // Setup Vault if err := s.setupVaultClient(); err != nil { s.Shutdown() @@ -346,6 +357,9 @@ func NewServer(config *Config, consulCatalog consul.CatalogAPI, logger *log.Logg // Emit metrics go s.heartbeatStats() + // Start the server health checking. + go s.autopilot.ServerHealthLoop(s.shutdownCh) + // Start enterprise background workers s.startEnterpriseBackground() @@ -425,8 +439,6 @@ func (s *Server) Leave() error { return err } - // TODO (alexdadgar) - This will need to be updated before 0.8 release to - // correctly handle using node IDs instead of address when raftProtocol = 3 addr := s.raftTransport.LocalAddr() // If we are the current leader, and we have any other peers (cluster has multiple @@ -435,9 +447,21 @@ func (s *Server) Leave() error { // for some sane period of time. isLeader := s.IsLeader() if isLeader && numPeers > 1 { - future := s.raft.RemovePeer(addr) - if err := future.Error(); err != nil { - s.logger.Printf("[ERR] nomad: failed to remove ourself as raft peer: %v", err) + minRaftProtocol, err := s.autopilot.MinRaftProtocol() + if err != nil { + return err + } + + if minRaftProtocol >= 2 && s.config.RaftConfig.ProtocolVersion >= 3 { + future := s.raft.RemoveServer(raft.ServerID(s.config.NodeID), 0, 0) + if err := future.Error(); err != nil { + s.logger.Printf("[ERR] nomad: failed to remove ourself as raft peer: %v", err) + } + } else { + future := s.raft.RemovePeer(addr) + if err := future.Error(); err != nil { + s.logger.Printf("[ERR] nomad: failed to remove ourself as raft peer: %v", err) + } } } @@ -777,6 +801,8 @@ func (s *Server) setupRPC(tlsWrap tlsutil.RegionWrapper) error { } s.rpcListener = list + s.logger.Printf("[INFO] nomad: RPC listening on %q", s.rpcListener.Addr().String()) + if s.config.RPCAdvertise != nil { s.rpcAdvertise = s.config.RPCAdvertise } else { @@ -935,8 +961,6 @@ func (s *Server) setupRaft() error { return err } if !hasState { - // TODO (alexdadgar) - This will need to be updated when - // we add support for node IDs. configuration := raft.Configuration{ Servers: []raft.Server{ { @@ -977,6 +1001,7 @@ func (s *Server) setupSerf(conf *serf.Config, ch chan serf.Event, path string) ( conf.Tags["build"] = s.config.Build conf.Tags["raft_vsn"] = fmt.Sprintf("%d", s.config.RaftConfig.ProtocolVersion) conf.Tags["id"] = s.config.NodeID + conf.Tags["rpc_addr"] = s.rpcAdvertise.(*net.TCPAddr).IP.String() conf.Tags["port"] = fmt.Sprintf("%d", s.rpcAdvertise.(*net.TCPAddr).Port) if s.config.Bootstrap || (s.config.DevMode && !s.config.DevDisableBootstrap) { conf.Tags["bootstrap"] = "1" @@ -985,6 +1010,9 @@ func (s *Server) setupSerf(conf *serf.Config, ch chan serf.Event, path string) ( if bootstrapExpect != 0 { conf.Tags["expect"] = fmt.Sprintf("%d", bootstrapExpect) } + if s.config.NonVoter { + conf.Tags["nonvoter"] = "1" + } conf.MemberlistConfig.LogOutput = s.config.LogOutput conf.LogOutput = s.config.LogOutput conf.EventCh = ch diff --git a/nomad/server_setup_oss.go b/nomad/server_setup_oss.go index b97260fc7..9755e46ee 100644 --- a/nomad/server_setup_oss.go +++ b/nomad/server_setup_oss.go @@ -2,9 +2,15 @@ package nomad +import "github.com/hashicorp/consul/agent/consul/autopilot" + type EnterpriseState struct{} func (s *Server) setupEnterprise(config *Config) error { + // Set up the OSS version of autopilot + apDelegate := &AutopilotDelegate{s} + s.autopilot = autopilot.NewAutopilot(s.logger, apDelegate, config.AutopilotInterval, config.ServerHealthInterval) + return nil } diff --git a/nomad/server_test.go b/nomad/server_test.go index 04175a290..8352f0b2d 100644 --- a/nomad/server_test.go +++ b/nomad/server_test.go @@ -55,7 +55,7 @@ func testACLServer(t *testing.T, cb func(*Config)) (*Server, *structs.ACLToken) func testServer(t *testing.T, cb func(*Config)) *Server { // Setup the default settings config := DefaultConfig() - config.Build = "0.7.0+unittest" + config.Build = "0.8.0+unittest" config.DevMode = true nodeNum := atomic.AddUint32(&nodeNumber, 1) config.NodeName = fmt.Sprintf("nomad-%03d", nodeNum) @@ -74,6 +74,11 @@ func testServer(t *testing.T, cb func(*Config)) *Server { config.RaftConfig.ElectionTimeout = 50 * time.Millisecond config.RaftTimeout = 500 * time.Millisecond + // Tighten the autopilot timing + config.AutopilotConfig.ServerStabilizationTime = 100 * time.Millisecond + config.ServerHealthInterval = 50 * time.Millisecond + config.AutopilotInterval = 100 * time.Millisecond + // Disable Vault f := false config.VaultConfig.Enabled = &f diff --git a/nomad/state/autopilot.go b/nomad/state/autopilot.go new file mode 100644 index 000000000..65654ca79 --- /dev/null +++ b/nomad/state/autopilot.go @@ -0,0 +1,104 @@ +package state + +import ( + "fmt" + + "github.com/hashicorp/consul/agent/consul/autopilot" + "github.com/hashicorp/go-memdb" +) + +// autopilotConfigTableSchema returns a new table schema used for storing +// the autopilot configuration +func autopilotConfigTableSchema() *memdb.TableSchema { + return &memdb.TableSchema{ + Name: "autopilot-config", + Indexes: map[string]*memdb.IndexSchema{ + "id": { + Name: "id", + AllowMissing: true, + Unique: true, + Indexer: &memdb.ConditionalIndex{ + Conditional: func(obj interface{}) (bool, error) { return true, nil }, + }, + }, + }, + } +} + +// AutopilotConfig is used to get the current Autopilot configuration. +func (s *StateStore) AutopilotConfig() (uint64, *autopilot.Config, error) { + tx := s.db.Txn(false) + defer tx.Abort() + + // Get the autopilot config + c, err := tx.First("autopilot-config", "id") + if err != nil { + return 0, nil, fmt.Errorf("failed autopilot config lookup: %s", err) + } + + config, ok := c.(*autopilot.Config) + if !ok { + return 0, nil, nil + } + + return config.ModifyIndex, config, nil +} + +// AutopilotSetConfig is used to set the current Autopilot configuration. +func (s *StateStore) AutopilotSetConfig(idx uint64, config *autopilot.Config) error { + tx := s.db.Txn(true) + defer tx.Abort() + + s.autopilotSetConfigTxn(idx, tx, config) + + tx.Commit() + return nil +} + +// AutopilotCASConfig is used to try updating the Autopilot configuration with a +// given Raft index. If the CAS index specified is not equal to the last observed index +// for the config, then the call is a noop, +func (s *StateStore) AutopilotCASConfig(idx, cidx uint64, config *autopilot.Config) (bool, error) { + tx := s.db.Txn(true) + defer tx.Abort() + + // Check for an existing config + existing, err := tx.First("autopilot-config", "id") + if err != nil { + return false, fmt.Errorf("failed autopilot config lookup: %s", err) + } + + // If the existing index does not match the provided CAS + // index arg, then we shouldn't update anything and can safely + // return early here. + e, ok := existing.(*autopilot.Config) + if !ok || e.ModifyIndex != cidx { + return false, nil + } + + s.autopilotSetConfigTxn(idx, tx, config) + + tx.Commit() + return true, nil +} + +func (s *StateStore) autopilotSetConfigTxn(idx uint64, tx *memdb.Txn, config *autopilot.Config) error { + // Check for an existing config + existing, err := tx.First("autopilot-config", "id") + if err != nil { + return fmt.Errorf("failed autopilot config lookup: %s", err) + } + + // Set the indexes. + if existing != nil { + config.CreateIndex = existing.(*autopilot.Config).CreateIndex + } else { + config.CreateIndex = idx + } + config.ModifyIndex = idx + + if err := tx.Insert("autopilot-config", config); err != nil { + return fmt.Errorf("failed updating autopilot config: %s", err) + } + return nil +} diff --git a/nomad/state/autopilot_test.go b/nomad/state/autopilot_test.go new file mode 100644 index 000000000..59bf7b417 --- /dev/null +++ b/nomad/state/autopilot_test.go @@ -0,0 +1,94 @@ +package state + +import ( + "reflect" + "testing" + "time" + + "github.com/hashicorp/consul/agent/consul/autopilot" +) + +func TestStateStore_Autopilot(t *testing.T) { + s := testStateStore(t) + + expected := &autopilot.Config{ + CleanupDeadServers: true, + LastContactThreshold: 5 * time.Second, + MaxTrailingLogs: 500, + ServerStabilizationTime: 100 * time.Second, + RedundancyZoneTag: "az", + DisableUpgradeMigration: true, + UpgradeVersionTag: "build", + } + + if err := s.AutopilotSetConfig(0, expected); err != nil { + t.Fatal(err) + } + + idx, config, err := s.AutopilotConfig() + if err != nil { + t.Fatal(err) + } + if idx != 0 { + t.Fatalf("bad: %d", idx) + } + if !reflect.DeepEqual(expected, config) { + t.Fatalf("bad: %#v, %#v", expected, config) + } +} + +func TestStateStore_AutopilotCAS(t *testing.T) { + s := testStateStore(t) + + expected := &autopilot.Config{ + CleanupDeadServers: true, + } + + if err := s.AutopilotSetConfig(0, expected); err != nil { + t.Fatal(err) + } + if err := s.AutopilotSetConfig(1, expected); err != nil { + t.Fatal(err) + } + + // Do a CAS with an index lower than the entry + ok, err := s.AutopilotCASConfig(2, 0, &autopilot.Config{ + CleanupDeadServers: false, + }) + if ok || err != nil { + t.Fatalf("expected (false, nil), got: (%v, %#v)", ok, err) + } + + // Check that the index is untouched and the entry + // has not been updated. + idx, config, err := s.AutopilotConfig() + if err != nil { + t.Fatal(err) + } + if idx != 1 { + t.Fatalf("bad: %d", idx) + } + if !config.CleanupDeadServers { + t.Fatalf("bad: %#v", config) + } + + // Do another CAS, this time with the correct index + ok, err = s.AutopilotCASConfig(2, 1, &autopilot.Config{ + CleanupDeadServers: false, + }) + if !ok || err != nil { + t.Fatalf("expected (true, nil), got: (%v, %#v)", ok, err) + } + + // Make sure the config was updated + idx, config, err = s.AutopilotConfig() + if err != nil { + t.Fatal(err) + } + if idx != 2 { + t.Fatalf("bad: %d", idx) + } + if config.CleanupDeadServers { + t.Fatalf("bad: %#v", config) + } +} diff --git a/nomad/state/schema.go b/nomad/state/schema.go index 89bc9ed0a..754cbf822 100644 --- a/nomad/state/schema.go +++ b/nomad/state/schema.go @@ -43,6 +43,7 @@ func init() { vaultAccessorTableSchema, aclPolicyTableSchema, aclTokenTableSchema, + autopilotConfigTableSchema, }...) } diff --git a/nomad/stats_fetcher.go b/nomad/stats_fetcher.go new file mode 100644 index 000000000..3d59ad6cb --- /dev/null +++ b/nomad/stats_fetcher.go @@ -0,0 +1,103 @@ +package nomad + +import ( + "context" + "log" + "sync" + + "github.com/hashicorp/consul/agent/consul/autopilot" + "github.com/hashicorp/serf/serf" +) + +// StatsFetcher has two functions for autopilot. First, lets us fetch all the +// stats in parallel so we are taking a sample as close to the same time as +// possible, since we are comparing time-sensitive info for the health check. +// Second, it bounds the time so that one slow RPC can't hold up the health +// check loop; as a side effect of how it implements this, it also limits to +// a single in-flight RPC to any given server, so goroutines don't accumulate +// as we run the health check fairly frequently. +type StatsFetcher struct { + logger *log.Logger + pool *ConnPool + region string + inflight map[string]struct{} + inflightLock sync.Mutex +} + +// NewStatsFetcher returns a stats fetcher. +func NewStatsFetcher(logger *log.Logger, pool *ConnPool, region string) *StatsFetcher { + return &StatsFetcher{ + logger: logger, + pool: pool, + region: region, + inflight: make(map[string]struct{}), + } +} + +// fetch does the RPC to fetch the server stats from a single server. We don't +// cancel this when the context is canceled because we only want one in-flight +// RPC to each server, so we let it finish and then clean up the in-flight +// tracking. +func (f *StatsFetcher) fetch(server *serverParts, replyCh chan *autopilot.ServerStats) { + var args struct{} + var reply autopilot.ServerStats + err := f.pool.RPC(f.region, server.RPCAddr, server.MajorVersion, "Status.RaftStats", &args, &reply) + if err != nil { + f.logger.Printf("[WARN] nomad: error getting server health from %q: %v", + server.Name, err) + } else { + replyCh <- &reply + } + + f.inflightLock.Lock() + delete(f.inflight, server.ID) + f.inflightLock.Unlock() +} + +// Fetch will attempt to query all the servers in parallel. +func (f *StatsFetcher) Fetch(ctx context.Context, members []serf.Member) map[string]*autopilot.ServerStats { + type workItem struct { + server *serverParts + replyCh chan *autopilot.ServerStats + } + var servers []*serverParts + for _, s := range members { + if ok, parts := isNomadServer(s); ok { + servers = append(servers, parts) + } + } + + // Skip any servers that have inflight requests. + var work []*workItem + f.inflightLock.Lock() + for _, server := range servers { + if _, ok := f.inflight[server.ID]; ok { + f.logger.Printf("[WARN] nomad: error getting server health from %q: last request still outstanding", + server.Name) + } else { + workItem := &workItem{ + server: server, + replyCh: make(chan *autopilot.ServerStats, 1), + } + work = append(work, workItem) + f.inflight[server.ID] = struct{}{} + go f.fetch(workItem.server, workItem.replyCh) + } + } + f.inflightLock.Unlock() + + // Now wait for the results to come in, or for the context to be + // canceled. + replies := make(map[string]*autopilot.ServerStats) + for _, workItem := range work { + select { + case reply := <-workItem.replyCh: + replies[workItem.server.ID] = reply + + case <-ctx.Done(): + f.logger.Printf("[WARN] nomad: error getting server health from %q: %v", + workItem.server.Name, ctx.Err()) + } + } + return replies +} diff --git a/nomad/stats_fetcher_test.go b/nomad/stats_fetcher_test.go new file mode 100644 index 000000000..a6b0052d1 --- /dev/null +++ b/nomad/stats_fetcher_test.go @@ -0,0 +1,95 @@ +package nomad + +import ( + "context" + "testing" + "time" + + "github.com/hashicorp/nomad/testutil" +) + +func TestStatsFetcher(t *testing.T) { + t.Parallel() + + conf := func(c *Config) { + c.Region = "region-a" + c.DevDisableBootstrap = true + c.BootstrapExpect = 3 + } + + s1 := testServer(t, conf) + defer s1.Shutdown() + + s2 := testServer(t, conf) + defer s2.Shutdown() + + s3 := testServer(t, conf) + defer s3.Shutdown() + + testJoin(t, s1, s2, s3) + testutil.WaitForLeader(t, s1.RPC) + + members := s1.serf.Members() + if len(members) != 3 { + t.Fatalf("bad len: %d", len(members)) + } + + var servers []*serverParts + for _, member := range members { + ok, server := isNomadServer(member) + if !ok { + t.Fatalf("bad: %#v", member) + } + servers = append(servers, server) + } + + // Do a normal fetch and make sure we get three responses. + func() { + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + stats := s1.statsFetcher.Fetch(ctx, s1.Members()) + if len(stats) != 3 { + t.Fatalf("bad: %#v", stats) + } + for id, stat := range stats { + switch id { + case s1.config.NodeID, s2.config.NodeID, s3.config.NodeID: + // OK + default: + t.Fatalf("bad: %s", id) + } + + if stat == nil || stat.LastTerm == 0 { + t.Fatalf("bad: %#v", stat) + } + } + }() + + // Fake an in-flight request to server 3 and make sure we don't fetch + // from it. + func() { + s1.statsFetcher.inflight[string(s3.config.NodeID)] = struct{}{} + defer delete(s1.statsFetcher.inflight, string(s3.config.NodeID)) + + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + stats := s1.statsFetcher.Fetch(ctx, s1.Members()) + if len(stats) != 2 { + t.Fatalf("bad: %#v", stats) + } + for id, stat := range stats { + switch id { + case s1.config.NodeID, s2.config.NodeID: + // OK + case s3.config.NodeID: + t.Fatalf("bad") + default: + t.Fatalf("bad: %s", id) + } + + if stat == nil || stat.LastTerm == 0 { + t.Fatalf("bad: %#v", stat) + } + } + }() +} diff --git a/nomad/status_endpoint.go b/nomad/status_endpoint.go index cbe2af325..baa700ff5 100644 --- a/nomad/status_endpoint.go +++ b/nomad/status_endpoint.go @@ -1,6 +1,10 @@ package nomad import ( + "fmt" + "strconv" + + "github.com/hashicorp/consul/agent/consul/autopilot" "github.com/hashicorp/nomad/nomad/structs" ) @@ -104,3 +108,21 @@ func (s *Status) Members(args *structs.GenericRequest, reply *structs.ServerMemb } return nil } + +// Used by Autopilot to query the raft stats of the local server. +func (s *Status) RaftStats(args struct{}, reply *autopilot.ServerStats) error { + stats := s.srv.raft.Stats() + + var err error + reply.LastContact = stats["last_contact"] + reply.LastIndex, err = strconv.ParseUint(stats["last_log_index"], 10, 64) + if err != nil { + return fmt.Errorf("error parsing server's last_log_index value: %s", err) + } + reply.LastTerm, err = strconv.ParseUint(stats["last_log_term"], 10, 64) + if err != nil { + return fmt.Errorf("error parsing server's last_log_term value: %s", err) + } + + return nil +} diff --git a/nomad/structs/config/autopilot.go b/nomad/structs/config/autopilot.go new file mode 100644 index 000000000..b1501b82f --- /dev/null +++ b/nomad/structs/config/autopilot.go @@ -0,0 +1,98 @@ +package config + +import ( + "time" + + "github.com/hashicorp/nomad/helper" +) + +type AutopilotConfig struct { + // CleanupDeadServers controls whether to remove dead servers when a new + // server is added to the Raft peers. + CleanupDeadServers *bool `mapstructure:"cleanup_dead_servers"` + + // ServerStabilizationTime is the minimum amount of time a server must be + // in a stable, healthy state before it can be added to the cluster. Only + // applicable with Raft protocol version 3 or higher. + ServerStabilizationTime time.Duration `mapstructure:"server_stabilization_time"` + + // LastContactThreshold is the limit on the amount of time a server can go + // without leader contact before being considered unhealthy. + LastContactThreshold time.Duration `mapstructure:"last_contact_threshold"` + + // MaxTrailingLogs is the amount of entries in the Raft Log that a server can + // be behind before being considered unhealthy. + MaxTrailingLogs int `mapstructure:"max_trailing_logs"` + + // (Enterprise-only) RedundancyZoneTag is the node tag to use for separating + // servers into zones for redundancy. If left blank, this feature will be disabled. + RedundancyZoneTag string `mapstructure:"redundancy_zone_tag"` + + // (Enterprise-only) DisableUpgradeMigration will disable Autopilot's upgrade migration + // strategy of waiting until enough newer-versioned servers have been added to the + // cluster before promoting them to voters. + DisableUpgradeMigration *bool `mapstructure:"disable_upgrade_migration"` + + // (Enterprise-only) UpgradeVersionTag is the node tag to use for version info when + // performing upgrade migrations. If left blank, the Nomad version will be used. + UpgradeVersionTag string `mapstructure:"upgrade_version_tag"` +} + +// DefaultAutopilotConfig() returns the canonical defaults for the Nomad +// `autopilot` configuration. +func DefaultAutopilotConfig() *AutopilotConfig { + return &AutopilotConfig{ + CleanupDeadServers: helper.BoolToPtr(true), + LastContactThreshold: 200 * time.Millisecond, + MaxTrailingLogs: 250, + ServerStabilizationTime: 10 * time.Second, + } +} + +func (a *AutopilotConfig) Merge(b *AutopilotConfig) *AutopilotConfig { + result := a.Copy() + + if b.CleanupDeadServers != nil { + result.CleanupDeadServers = helper.BoolToPtr(*b.CleanupDeadServers) + } + if b.ServerStabilizationTime != 0 { + result.ServerStabilizationTime = b.ServerStabilizationTime + } + if b.LastContactThreshold != 0 { + result.LastContactThreshold = b.LastContactThreshold + } + if b.MaxTrailingLogs != 0 { + result.MaxTrailingLogs = b.MaxTrailingLogs + } + if b.RedundancyZoneTag != "" { + result.RedundancyZoneTag = b.RedundancyZoneTag + } + if b.DisableUpgradeMigration != nil { + result.DisableUpgradeMigration = helper.BoolToPtr(*b.DisableUpgradeMigration) + } + if b.UpgradeVersionTag != "" { + result.UpgradeVersionTag = b.UpgradeVersionTag + } + + return result +} + +// Copy returns a copy of this Autopilot config. +func (a *AutopilotConfig) Copy() *AutopilotConfig { + if a == nil { + return nil + } + + nc := new(AutopilotConfig) + *nc = *a + + // Copy the bools + if a.CleanupDeadServers != nil { + nc.CleanupDeadServers = helper.BoolToPtr(*a.CleanupDeadServers) + } + if a.DisableUpgradeMigration != nil { + nc.DisableUpgradeMigration = helper.BoolToPtr(*a.DisableUpgradeMigration) + } + + return nc +} diff --git a/nomad/structs/config/autopilot_test.go b/nomad/structs/config/autopilot_test.go new file mode 100644 index 000000000..1dcb725a0 --- /dev/null +++ b/nomad/structs/config/autopilot_test.go @@ -0,0 +1,46 @@ +package config + +import ( + "reflect" + "testing" + "time" +) + +func TestAutopilotConfig_Merge(t *testing.T) { + trueValue, falseValue := true, false + + c1 := &AutopilotConfig{ + CleanupDeadServers: &falseValue, + ServerStabilizationTime: 1 * time.Second, + LastContactThreshold: 1 * time.Second, + MaxTrailingLogs: 1, + RedundancyZoneTag: "1", + DisableUpgradeMigration: &falseValue, + UpgradeVersionTag: "1", + } + + c2 := &AutopilotConfig{ + CleanupDeadServers: &trueValue, + ServerStabilizationTime: 2 * time.Second, + LastContactThreshold: 2 * time.Second, + MaxTrailingLogs: 2, + RedundancyZoneTag: "2", + DisableUpgradeMigration: nil, + UpgradeVersionTag: "2", + } + + e := &AutopilotConfig{ + CleanupDeadServers: &trueValue, + ServerStabilizationTime: 2 * time.Second, + LastContactThreshold: 2 * time.Second, + MaxTrailingLogs: 2, + RedundancyZoneTag: "2", + DisableUpgradeMigration: &falseValue, + UpgradeVersionTag: "2", + } + + result := c1.Merge(c2) + if !reflect.DeepEqual(result, e) { + t.Fatalf("bad:\n%#v\n%#v", result, e) + } +} diff --git a/nomad/structs/operator.go b/nomad/structs/operator.go index 22e37ae79..fe83ec86f 100644 --- a/nomad/structs/operator.go +++ b/nomad/structs/operator.go @@ -1,6 +1,7 @@ package structs import ( + "github.com/hashicorp/consul/agent/consul/autopilot" "github.com/hashicorp/raft" ) @@ -50,3 +51,34 @@ type RaftPeerByAddressRequest struct { // WriteRequest holds the Region for this request. WriteRequest } + +// RaftPeerByIDRequest is used by the Operator endpoint to apply a Raft +// operation on a specific Raft peer by ID. +type RaftPeerByIDRequest struct { + // ID is the peer ID to remove. + ID raft.ServerID + + // WriteRequest holds the Region for this request. + WriteRequest +} + +// AutopilotSetConfigRequest is used by the Operator endpoint to update the +// current Autopilot configuration of the cluster. +type AutopilotSetConfigRequest struct { + // Datacenter is the target this request is intended for. + Datacenter string + + // Config is the new Autopilot configuration to use. + Config autopilot.Config + + // CAS controls whether to use check-and-set semantics for this request. + CAS bool + + // WriteRequest holds the ACL token to go along with this request. + WriteRequest +} + +// RequestDatacenter returns the datacenter for a given request. +func (op *AutopilotSetConfigRequest) RequestDatacenter() string { + return op.Datacenter +} diff --git a/nomad/structs/structs.go b/nomad/structs/structs.go index 32e624cd2..c820caa6d 100644 --- a/nomad/structs/structs.go +++ b/nomad/structs/structs.go @@ -78,6 +78,7 @@ const ( ACLTokenUpsertRequestType ACLTokenDeleteRequestType ACLTokenBootstrapRequestType + AutopilotRequestType ) const ( diff --git a/nomad/util.go b/nomad/util.go index 8f789a969..8b02a585f 100644 --- a/nomad/util.go +++ b/nomad/util.go @@ -46,7 +46,9 @@ type serverParts struct { MinorVersion int Build version.Version RaftVersion int + NonVoter bool Addr net.Addr + RPCAddr net.Addr Status serf.MemberStatus } @@ -69,24 +71,31 @@ func isNomadServer(m serf.Member) (bool, *serverParts) { region := m.Tags["region"] datacenter := m.Tags["dc"] _, bootstrap := m.Tags["bootstrap"] + _, nonVoter := m.Tags["nonvoter"] expect := 0 - expect_str, ok := m.Tags["expect"] + expectStr, ok := m.Tags["expect"] var err error if ok { - expect, err = strconv.Atoi(expect_str) + expect, err = strconv.Atoi(expectStr) if err != nil { return false, nil } } - port_str := m.Tags["port"] - port, err := strconv.Atoi(port_str) + // If the server is missing the rpc_addr tag, default to the serf advertise addr + rpcIP := net.ParseIP(m.Tags["rpc_addr"]) + if rpcIP == nil { + rpcIP = m.Addr + } + + portStr := m.Tags["port"] + port, err := strconv.Atoi(portStr) if err != nil { return false, nil } - build_version, err := version.NewVersion(m.Tags["build"]) + buildVersion, err := version.NewVersion(m.Tags["build"]) if err != nil { return false, nil } @@ -106,16 +115,17 @@ func isNomadServer(m serf.Member) (bool, *serverParts) { minorVersion = 0 } - raft_vsn := 0 - raft_vsn_str, ok := m.Tags["raft_vsn"] + raftVsn := 0 + raftVsnString, ok := m.Tags["raft_vsn"] if ok { - raft_vsn, err = strconv.Atoi(raft_vsn_str) + raftVsn, err = strconv.Atoi(raftVsnString) if err != nil { return false, nil } } addr := &net.TCPAddr{IP: m.Addr, Port: port} + rpcAddr := &net.TCPAddr{IP: rpcIP, Port: port} parts := &serverParts{ Name: m.Name, ID: id, @@ -125,10 +135,12 @@ func isNomadServer(m serf.Member) (bool, *serverParts) { Bootstrap: bootstrap, Expect: expect, Addr: addr, + RPCAddr: rpcAddr, MajorVersion: majorVersion, MinorVersion: minorVersion, - Build: *build_version, - RaftVersion: raft_vsn, + Build: *buildVersion, + RaftVersion: raftVsn, + NonVoter: nonVoter, Status: m.Status, } return true, parts @@ -139,7 +151,10 @@ func isNomadServer(m serf.Member) (bool, *serverParts) { func ServersMeetMinimumVersion(members []serf.Member, minVersion *version.Version) bool { for _, member := range members { if valid, parts := isNomadServer(member); valid && parts.Status == serf.StatusAlive { - if parts.Build.LessThan(minVersion) { + // Check if the versions match - version.LessThan will return true for + // 0.8.0-rc1 < 0.8.0, so we want to ignore the metadata + versionsMatch := slicesMatch(minVersion.Segments(), parts.Build.Segments()) + if parts.Build.LessThan(minVersion) && !versionsMatch { return false } } @@ -148,34 +163,26 @@ func ServersMeetMinimumVersion(members []serf.Member, minVersion *version.Versio return true } -// MinRaftProtocol returns the lowest supported Raft protocol among alive servers -// in the given region. -func MinRaftProtocol(region string, members []serf.Member) (int, error) { - minVersion := -1 - for _, m := range members { - if m.Tags["role"] != "nomad" || m.Tags["region"] != region || m.Status != serf.StatusAlive { - continue - } +func slicesMatch(a, b []int) bool { + if a == nil && b == nil { + return true + } - vsn, ok := m.Tags["raft_vsn"] - if !ok { - vsn = "1" - } - raftVsn, err := strconv.Atoi(vsn) - if err != nil { - return -1, err - } + if a == nil || b == nil { + return false + } - if minVersion == -1 || raftVsn < minVersion { - minVersion = raftVsn + if len(a) != len(b) { + return false + } + + for i := range a { + if a[i] != b[i] { + return false } } - if minVersion == -1 { - return minVersion, fmt.Errorf("no servers found") - } - - return minVersion, nil + return true } // shuffleStrings randomly shuffles the list of strings diff --git a/nomad/util_test.go b/nomad/util_test.go index 82e939491..12db5100c 100644 --- a/nomad/util_test.go +++ b/nomad/util_test.go @@ -1,7 +1,6 @@ package nomad import ( - "errors" "net" "reflect" "testing" @@ -18,12 +17,15 @@ func TestIsNomadServer(t *testing.T) { Addr: net.IP([]byte{127, 0, 0, 1}), Status: serf.StatusAlive, Tags: map[string]string{ - "role": "nomad", - "region": "aws", - "dc": "east-aws", - "port": "10000", - "vsn": "1", - "build": "0.7.0+ent", + "role": "nomad", + "region": "aws", + "dc": "east-aws", + "rpc_addr": "1.1.1.1", + "port": "10000", + "vsn": "1", + "raft_vsn": "2", + "nonvoter": "1", + "build": "0.7.0+ent", }, } valid, parts := isNomadServer(m) @@ -43,6 +45,15 @@ func TestIsNomadServer(t *testing.T) { if parts.Status != serf.StatusAlive { t.Fatalf("bad: %v", parts.Status) } + if parts.RaftVersion != 2 { + t.Fatalf("bad: %v", parts.RaftVersion) + } + if parts.RPCAddr.String() != "1.1.1.1:10000" { + t.Fatalf("bad: %v", parts.RPCAddr.String()) + } + if !parts.NonVoter { + t.Fatalf("bad: %v", parts.NonVoter) + } if seg := parts.Build.Segments(); len(seg) != 3 { t.Fatalf("bad: %v", parts.Build) } else if seg[0] != 0 && seg[1] != 7 && seg[2] != 0 { @@ -152,105 +163,6 @@ func TestServersMeetMinimumVersion(t *testing.T) { } } -func TestMinRaftProtocol(t *testing.T) { - t.Parallel() - makeMember := func(version, region string) serf.Member { - return serf.Member{ - Name: "foo", - Addr: net.IP([]byte{127, 0, 0, 1}), - Tags: map[string]string{ - "role": "nomad", - "region": region, - "dc": "dc1", - "port": "10000", - "vsn": "1", - "raft_vsn": version, - }, - Status: serf.StatusAlive, - } - } - - cases := []struct { - members []serf.Member - region string - expected int - err error - }{ - // No servers, error - { - members: []serf.Member{}, - expected: -1, - err: errors.New("no servers found"), - }, - // One server - { - members: []serf.Member{ - makeMember("1", "global"), - }, - region: "global", - expected: 1, - }, - // One server, bad version formatting - { - members: []serf.Member{ - makeMember("asdf", "global"), - }, - region: "global", - expected: -1, - err: errors.New(`strconv.Atoi: parsing "asdf": invalid syntax`), - }, - // One server, wrong datacenter - { - members: []serf.Member{ - makeMember("1", "global"), - }, - region: "nope", - expected: -1, - err: errors.New("no servers found"), - }, - // Multiple servers, different versions - { - members: []serf.Member{ - makeMember("1", "global"), - makeMember("2", "global"), - }, - region: "global", - expected: 1, - }, - // Multiple servers, same version - { - members: []serf.Member{ - makeMember("2", "global"), - makeMember("2", "global"), - }, - region: "global", - expected: 2, - }, - // Multiple servers, multiple datacenters - { - members: []serf.Member{ - makeMember("3", "r1"), - makeMember("2", "r1"), - makeMember("1", "r2"), - }, - region: "r1", - expected: 2, - }, - } - - for _, tc := range cases { - result, err := MinRaftProtocol(tc.region, tc.members) - if result != tc.expected { - t.Fatalf("bad: %v, %v, %v", result, tc.expected, tc) - } - if tc.err != nil { - if err == nil || tc.err.Error() != err.Error() { - t.Fatalf("bad: %v, %v, %v", err, tc.err, tc) - } - } - } -} - func TestShuffleStrings(t *testing.T) { t.Parallel() // Generate input diff --git a/testutil/server.go b/testutil/server.go index 5d8dfeac4..80573e8a6 100644 --- a/testutil/server.go +++ b/testutil/server.go @@ -62,6 +62,7 @@ type PortsConfig struct { type ServerConfig struct { Enabled bool `json:"enabled"` BootstrapExpect int `json:"bootstrap_expect"` + RaftProtocol int `json:"raft_protocol,omitempty"` } // ClientConfig is used to configure the client diff --git a/ui/.nvmrc b/ui/.nvmrc index 1e8b31496..45a4fb75d 100644 --- a/ui/.nvmrc +++ b/ui/.nvmrc @@ -1 +1 @@ -6 +8 diff --git a/ui/.prettierrc b/ui/.prettierrc new file mode 100644 index 000000000..7f9eaa64f --- /dev/null +++ b/ui/.prettierrc @@ -0,0 +1,3 @@ +printWidth: 100 +singleQuote: true +trailingComma: es5 diff --git a/ui/.travis.yml b/ui/.travis.yml deleted file mode 100644 index 385f003d4..000000000 --- a/ui/.travis.yml +++ /dev/null @@ -1,21 +0,0 @@ ---- -language: node_js -node_js: - - "6" - -sudo: false - -cache: - directories: - - $HOME/.npm - -before_install: - - npm config set spin false - - npm install -g phantomjs-prebuilt - - phantomjs --version - -install: - - npm install - -script: - - npm test diff --git a/ui/app/adapters/application.js b/ui/app/adapters/application.js index 1e5c97333..bcf223383 100644 --- a/ui/app/adapters/application.js +++ b/ui/app/adapters/application.js @@ -1,15 +1,14 @@ -import Ember from 'ember'; +import { inject as service } from '@ember/service'; +import { computed, get } from '@ember/object'; import RESTAdapter from 'ember-data/adapters/rest'; import codesForError from '../utils/codes-for-error'; -const { get, computed, inject } = Ember; - export const namespace = 'v1'; export default RESTAdapter.extend({ namespace, - token: inject.service(), + token: service(), headers: computed('token.secret', function() { const token = this.get('token.secret'); diff --git a/ui/app/adapters/job.js b/ui/app/adapters/job.js index 4d18d1625..80398adac 100644 --- a/ui/app/adapters/job.js +++ b/ui/app/adapters/job.js @@ -1,10 +1,10 @@ -import Ember from 'ember'; +import { inject as service } from '@ember/service'; +import RSVP from 'rsvp'; +import { assign } from '@ember/polyfills'; import ApplicationAdapter from './application'; -const { RSVP, inject, assign } = Ember; - export default ApplicationAdapter.extend({ - system: inject.service(), + system: service(), shouldReloadAll: () => true, diff --git a/ui/app/adapters/token.js b/ui/app/adapters/token.js index 0bd103334..32315037a 100644 --- a/ui/app/adapters/token.js +++ b/ui/app/adapters/token.js @@ -1,10 +1,8 @@ -import Ember from 'ember'; +import { inject as service } from '@ember/service'; import { default as ApplicationAdapter, namespace } from './application'; -const { inject } = Ember; - export default ApplicationAdapter.extend({ - store: inject.service(), + store: service(), namespace: namespace + '/acl', diff --git a/ui/app/app.js b/ui/app/app.js index 831ad6106..7d6bae3ce 100644 --- a/ui/app/app.js +++ b/ui/app/app.js @@ -1,16 +1,14 @@ -import Ember from 'ember'; +import Application from '@ember/application'; import Resolver from './resolver'; import loadInitializers from 'ember-load-initializers'; import config from './config/environment'; let App; -Ember.MODEL_FACTORY_INJECTIONS = true; - -App = Ember.Application.extend({ +App = Application.extend({ modulePrefix: config.modulePrefix, podModulePrefix: config.podModulePrefix, - Resolver + Resolver, }); loadInitializers(App, config.modulePrefix); diff --git a/ui/app/components/allocation-row.js b/ui/app/components/allocation-row.js index a258f5b24..5625c092f 100644 --- a/ui/app/components/allocation-row.js +++ b/ui/app/components/allocation-row.js @@ -1,10 +1,10 @@ -import Ember from 'ember'; +import { inject as service } from '@ember/service'; +import Component from '@ember/component'; +import { run } from '@ember/runloop'; import { lazyClick } from '../helpers/lazy-click'; -const { Component, inject, run } = Ember; - export default Component.extend({ - store: inject.service(), + store: service(), tagName: 'tr', diff --git a/ui/app/components/allocation-status-bar.js b/ui/app/components/allocation-status-bar.js index 0a3734626..7f2d1e2fc 100644 --- a/ui/app/components/allocation-status-bar.js +++ b/ui/app/components/allocation-status-bar.js @@ -1,8 +1,6 @@ -import Ember from 'ember'; +import { computed } from '@ember/object'; import DistributionBar from './distribution-bar'; -const { computed } = Ember; - export default DistributionBar.extend({ layoutName: 'components/distribution-bar', diff --git a/ui/app/components/attributes-section.js b/ui/app/components/attributes-section.js index f2503c9fc..479865264 100644 --- a/ui/app/components/attributes-section.js +++ b/ui/app/components/attributes-section.js @@ -1,6 +1,4 @@ -import Ember from 'ember'; - -const { Component } = Ember; +import Component from '@ember/component'; export default Component.extend({ tagName: '', diff --git a/ui/app/components/client-node-row.js b/ui/app/components/client-node-row.js index e2f9dd19c..2775ed58f 100644 --- a/ui/app/components/client-node-row.js +++ b/ui/app/components/client-node-row.js @@ -1,8 +1,6 @@ -import Ember from 'ember'; +import Component from '@ember/component'; import { lazyClick } from '../helpers/lazy-click'; -const { Component } = Ember; - export default Component.extend({ tagName: 'tr', classNames: ['client-node-row', 'is-interactive'], diff --git a/ui/app/components/distribution-bar.js b/ui/app/components/distribution-bar.js index 105273df6..46d47d8f4 100644 --- a/ui/app/components/distribution-bar.js +++ b/ui/app/components/distribution-bar.js @@ -1,10 +1,13 @@ -import Ember from 'ember'; +import Component from '@ember/component'; +import { computed } from '@ember/object'; +import { run } from '@ember/runloop'; +import { assign } from '@ember/polyfills'; +import { guidFor } from '@ember/object/internals'; import d3 from 'npm:d3-selection'; import 'npm:d3-transition'; import WindowResizable from '../mixins/window-resizable'; import styleStringProperty from '../utils/properties/style-string'; -const { Component, computed, run, assign, guidFor } = Ember; const sumAggregate = (total, val) => total + val; export default Component.extend(WindowResizable, { @@ -96,7 +99,7 @@ export default Component.extend(WindowResizable, { }); slices = slices.merge(slicesEnter); - slices.attr('class', d => d.className || `slice-${filteredData.indexOf(d)}`); + slices.attr('class', d => d.className || `slice-${_data.indexOf(d)}`); const setWidth = d => `${width * d.percent - (d.index === sliceCount - 1 || d.index === 0 ? 1 : 2)}px` const setOffset = d => `${width * d.offset + (d.index === 0 ? 0 : 1)}px` diff --git a/ui/app/components/freestyle/sg-boxed-section.js b/ui/app/components/freestyle/sg-boxed-section.js new file mode 100644 index 000000000..4e4f4fa7d --- /dev/null +++ b/ui/app/components/freestyle/sg-boxed-section.js @@ -0,0 +1,27 @@ +import Component from '@ember/component'; +import { computed } from '@ember/object'; + +export default Component.extend({ + variants: computed(() => [ + { + key: 'Normal', + title: 'Normal', + slug: '', + }, + { + key: 'Info', + title: 'Info', + slug: 'is-info', + }, + { + key: 'Warning', + title: 'Warning', + slug: 'is-warning', + }, + { + key: 'Danger', + title: 'Danger', + slug: 'is-danger', + }, + ]), +}); diff --git a/ui/app/components/freestyle/sg-colors.js b/ui/app/components/freestyle/sg-colors.js new file mode 100644 index 000000000..40d77fcd2 --- /dev/null +++ b/ui/app/components/freestyle/sg-colors.js @@ -0,0 +1,97 @@ +import Component from '@ember/component'; +import { computed } from '@ember/object'; + +export default Component.extend({ + nomadTheme: computed(() => [ + { + name: 'Primary', + base: '#25ba81', + }, + { + name: 'Primary Dark', + base: '#1d9467', + }, + { + name: 'Text', + base: '#0a0a0a', + }, + { + name: 'Link', + base: '#1563ff', + }, + { + name: 'Gray', + base: '#bbc4d1', + }, + { + name: 'Off-white', + base: '#f5f5f5', + }, + ]), + + productColors: computed(() => [ + { + name: 'Consul Pink', + base: '#ff0087', + }, + { + name: 'Consul Pink Dark', + base: '#c62a71', + }, + { + name: 'Packer Blue', + base: '#1daeff', + }, + { + name: 'Packer Blue Dark', + base: '#1d94dd', + }, + { + name: 'Terraform Purple', + base: '#5c4ee5', + }, + { + name: 'Terraform Purple Dark', + base: '#4040b2', + }, + { + name: 'Vagrant Blue', + base: '#1563ff', + }, + { + name: 'Vagrant Blue Dark', + base: '#104eb2', + }, + { + name: 'Nomad Green', + base: '#25ba81', + }, + { + name: 'Nomad Green Dark', + base: '#1d9467', + }, + { + name: 'Nomad Green Darker', + base: '#16704d', + }, + ]), + + emotiveColors: computed(() => [ + { + name: 'Success', + base: '#23d160', + }, + { + name: 'Warning', + base: '#fa8e23', + }, + { + name: 'Danger', + base: '#c84034', + }, + { + name: 'Info', + base: '#1563ff', + }, + ]), +}); diff --git a/ui/app/components/freestyle/sg-distribution-bar-jumbo.js b/ui/app/components/freestyle/sg-distribution-bar-jumbo.js new file mode 100644 index 000000000..9176d6b7c --- /dev/null +++ b/ui/app/components/freestyle/sg-distribution-bar-jumbo.js @@ -0,0 +1,13 @@ +import Component from '@ember/component'; +import { computed } from '@ember/object'; + +export default Component.extend({ + distributionBarData: computed(() => { + return [ + { label: 'one', value: 10 }, + { label: 'two', value: 20 }, + { label: 'three', value: 0 }, + { label: 'four', value: 35 }, + ]; + }), +}); diff --git a/ui/app/components/freestyle/sg-distribution-bar.js b/ui/app/components/freestyle/sg-distribution-bar.js new file mode 100644 index 000000000..ab43ceb03 --- /dev/null +++ b/ui/app/components/freestyle/sg-distribution-bar.js @@ -0,0 +1,43 @@ +import Component from '@ember/component'; +import { computed } from '@ember/object'; + +export default Component.extend({ + timerTicks: 0, + + startTimer: function() { + this.set( + 'timer', + setInterval(() => { + this.incrementProperty('timerTicks'); + }, 500) + ); + }.on('init'), + + willDestroy() { + clearInterval(this.get('timer')); + }, + + distributionBarData: computed(() => { + return [ + { label: 'one', value: 10 }, + { label: 'two', value: 20 }, + { label: 'three', value: 30 }, + ]; + }), + + distributionBarDataWithClasses: computed(() => { + return [ + { label: 'Queued', value: 10, className: 'queued' }, + { label: 'Complete', value: 20, className: 'complete' }, + { label: 'Failed', value: 30, className: 'failed' }, + ]; + }), + + distributionBarDataRotating: computed('timerTicks', () => { + return [ + { label: 'one', value: Math.round(Math.random() * 50) }, + { label: 'two', value: Math.round(Math.random() * 50) }, + { label: 'three', value: Math.round(Math.random() * 50) }, + ]; + }), +}); diff --git a/ui/app/components/gutter-menu.js b/ui/app/components/gutter-menu.js index 9ab098cbb..7a5086b76 100644 --- a/ui/app/components/gutter-menu.js +++ b/ui/app/components/gutter-menu.js @@ -1,9 +1,9 @@ -import Ember from 'ember'; - -const { Component, inject, computed } = Ember; +import { inject as service } from '@ember/service'; +import Component from '@ember/component'; +import { computed } from '@ember/object'; export default Component.extend({ - system: inject.service(), + system: service(), sortedNamespaces: computed('system.namespaces.@each.name', function() { const namespaces = this.get('system.namespaces').toArray() || []; diff --git a/ui/app/components/job-deployment.js b/ui/app/components/job-deployment.js index 1a2476be8..feb13f17d 100644 --- a/ui/app/components/job-deployment.js +++ b/ui/app/components/job-deployment.js @@ -1,6 +1,4 @@ -import Ember from 'ember'; - -const { Component } = Ember; +import Component from '@ember/component'; export default Component.extend({ classNames: ['job-deployment', 'boxed-section'], diff --git a/ui/app/components/job-deployment/deployment-metrics.js b/ui/app/components/job-deployment/deployment-metrics.js index f2503c9fc..479865264 100644 --- a/ui/app/components/job-deployment/deployment-metrics.js +++ b/ui/app/components/job-deployment/deployment-metrics.js @@ -1,6 +1,4 @@ -import Ember from 'ember'; - -const { Component } = Ember; +import Component from '@ember/component'; export default Component.extend({ tagName: '', diff --git a/ui/app/components/job-deployments-stream.js b/ui/app/components/job-deployments-stream.js index 95bd4f198..0ddb5fedb 100644 --- a/ui/app/components/job-deployments-stream.js +++ b/ui/app/components/job-deployments-stream.js @@ -1,17 +1,16 @@ -import Ember from 'ember'; +import Component from '@ember/component'; +import { computed } from '@ember/object'; import moment from 'moment'; -const { Component, computed } = Ember; - export default Component.extend({ tagName: 'ol', classNames: ['timeline'], deployments: computed(() => []), - sortedDeployments: computed('deployments.@each.version.submitTime', function() { + sortedDeployments: computed('deployments.@each.versionSubmitTime', function() { return this.get('deployments') - .sortBy('version.submitTime') + .sortBy('versionSubmitTime') .reverse(); }), diff --git a/ui/app/components/job-diff-fields-and-objects.js b/ui/app/components/job-diff-fields-and-objects.js index f2503c9fc..479865264 100644 --- a/ui/app/components/job-diff-fields-and-objects.js +++ b/ui/app/components/job-diff-fields-and-objects.js @@ -1,6 +1,4 @@ -import Ember from 'ember'; - -const { Component } = Ember; +import Component from '@ember/component'; export default Component.extend({ tagName: '', diff --git a/ui/app/components/job-diff.js b/ui/app/components/job-diff.js index 2bba7310e..79371f333 100644 --- a/ui/app/components/job-diff.js +++ b/ui/app/components/job-diff.js @@ -1,6 +1,5 @@ -import Ember from 'ember'; - -const { Component, computed } = Ember; +import { equal } from '@ember/object/computed'; +import Component from '@ember/component'; export default Component.extend({ classNames: ['job-diff'], @@ -10,7 +9,7 @@ export default Component.extend({ verbose: true, - isEdited: computed.equal('diff.Type', 'Edited'), - isAdded: computed.equal('diff.Type', 'Added'), - isDeleted: computed.equal('diff.Type', 'Deleted'), + isEdited: equal('diff.Type', 'Edited'), + isAdded: equal('diff.Type', 'Added'), + isDeleted: equal('diff.Type', 'Deleted'), }); diff --git a/ui/app/components/job-row.js b/ui/app/components/job-row.js index e370cfba9..db9e6e369 100644 --- a/ui/app/components/job-row.js +++ b/ui/app/components/job-row.js @@ -1,8 +1,6 @@ -import Ember from 'ember'; +import Component from '@ember/component'; import { lazyClick } from '../helpers/lazy-click'; -const { Component } = Ember; - export default Component.extend({ tagName: 'tr', classNames: ['job-row', 'is-interactive'], diff --git a/ui/app/components/job-version.js b/ui/app/components/job-version.js index 0ffe77803..978111a93 100644 --- a/ui/app/components/job-version.js +++ b/ui/app/components/job-version.js @@ -1,6 +1,5 @@ -import Ember from 'ember'; - -const { Component, computed } = Ember; +import Component from '@ember/component'; +import { computed } from '@ember/object'; const changeTypes = ['Added', 'Deleted', 'Edited']; diff --git a/ui/app/components/job-versions-stream.js b/ui/app/components/job-versions-stream.js index 8ac011376..b2a92d71a 100644 --- a/ui/app/components/job-versions-stream.js +++ b/ui/app/components/job-versions-stream.js @@ -1,8 +1,7 @@ -import Ember from 'ember'; +import Component from '@ember/component'; +import { computed } from '@ember/object'; import moment from 'moment'; -const { Component, computed } = Ember; - export default Component.extend({ tagName: 'ol', classNames: ['timeline'], diff --git a/ui/app/components/json-viewer.js b/ui/app/components/json-viewer.js index 2d8442e33..cf966757d 100644 --- a/ui/app/components/json-viewer.js +++ b/ui/app/components/json-viewer.js @@ -1,8 +1,8 @@ -import Ember from 'ember'; +import Component from '@ember/component'; +import { computed } from '@ember/object'; +import { run } from '@ember/runloop'; import JSONFormatterPkg from 'npm:json-formatter-js'; -const { Component, computed, run } = Ember; - // json-formatter-js is packaged in a funny way that ember-cli-browserify // doesn't unwrap properly. const { default: JSONFormatter } = JSONFormatterPkg; diff --git a/ui/app/components/list-pagination.js b/ui/app/components/list-pagination.js index c18301d4a..c04aaa9f4 100644 --- a/ui/app/components/list-pagination.js +++ b/ui/app/components/list-pagination.js @@ -1,6 +1,5 @@ -import Ember from 'ember'; - -const { Component, computed } = Ember; +import Component from '@ember/component'; +import { computed } from '@ember/object'; export default Component.extend({ source: computed(() => []), @@ -31,9 +30,11 @@ export default Component.extend({ const lowerBound = Math.max(1, page - spread); const upperBound = Math.min(lastPage, page + spread) + 1; - return Array(upperBound - lowerBound).fill(null).map((_, index) => ({ - pageNumber: lowerBound + index, - })); + return Array(upperBound - lowerBound) + .fill(null) + .map((_, index) => ({ + pageNumber: lowerBound + index, + })); }), list: computed('source.[]', 'page', 'size', function() { diff --git a/ui/app/components/list-pagination/list-pager.js b/ui/app/components/list-pagination/list-pager.js index f2503c9fc..479865264 100644 --- a/ui/app/components/list-pagination/list-pager.js +++ b/ui/app/components/list-pagination/list-pager.js @@ -1,6 +1,4 @@ -import Ember from 'ember'; - -const { Component } = Ember; +import Component from '@ember/component'; export default Component.extend({ tagName: '', diff --git a/ui/app/components/list-table.js b/ui/app/components/list-table.js index 39cf15fe6..0b6d63416 100644 --- a/ui/app/components/list-table.js +++ b/ui/app/components/list-table.js @@ -1,6 +1,5 @@ -import Ember from 'ember'; - -const { Component, computed } = Ember; +import Component from '@ember/component'; +import { computed } from '@ember/object'; export default Component.extend({ tagName: 'table', diff --git a/ui/app/components/list-table/sort-by.js b/ui/app/components/list-table/sort-by.js index 9edcb61e7..cd6e3fc2c 100644 --- a/ui/app/components/list-table/sort-by.js +++ b/ui/app/components/list-table/sort-by.js @@ -1,6 +1,5 @@ -import Ember from 'ember'; - -const { Component, computed } = Ember; +import Component from '@ember/component'; +import { computed } from '@ember/object'; export default Component.extend({ tagName: 'th', diff --git a/ui/app/components/list-table/table-body.js b/ui/app/components/list-table/table-body.js index 4c756a699..782917851 100644 --- a/ui/app/components/list-table/table-body.js +++ b/ui/app/components/list-table/table-body.js @@ -1,6 +1,4 @@ -import Ember from 'ember'; - -const { Component } = Ember; +import Component from '@ember/component'; export default Component.extend({ tagName: 'tbody', diff --git a/ui/app/components/list-table/table-head.js b/ui/app/components/list-table/table-head.js index c1f0c7825..92a17d670 100644 --- a/ui/app/components/list-table/table-head.js +++ b/ui/app/components/list-table/table-head.js @@ -1,6 +1,4 @@ -import Ember from 'ember'; - -const { Component } = Ember; +import Component from '@ember/component'; export default Component.extend({ tagName: 'thead', diff --git a/ui/app/components/search-box.js b/ui/app/components/search-box.js index 19aa76bef..8066f3e0b 100644 --- a/ui/app/components/search-box.js +++ b/ui/app/components/search-box.js @@ -1,13 +1,13 @@ -import Ember from 'ember'; - -const { Component, computed, run } = Ember; +import { reads } from '@ember/object/computed'; +import Component from '@ember/component'; +import { run } from '@ember/runloop'; export default Component.extend({ // Passed to the component (mutable) searchTerm: null, // Used as a debounce buffer - _searchTerm: computed.reads('searchTerm'), + _searchTerm: reads('searchTerm'), // Used to throttle sets to searchTerm debounce: 150, diff --git a/ui/app/components/server-agent-row.js b/ui/app/components/server-agent-row.js index f853ada96..98d401fe0 100644 --- a/ui/app/components/server-agent-row.js +++ b/ui/app/components/server-agent-row.js @@ -1,13 +1,15 @@ -import Ember from 'ember'; +import { inject as service } from '@ember/service'; +import { alias } from '@ember/object/computed'; +import Component from '@ember/component'; +import { computed } from '@ember/object'; import { lazyClick } from '../helpers/lazy-click'; -const { Component, inject, computed } = Ember; - export default Component.extend({ - // TODO Switch back to the router service style when it is no longer feature-flagged + // TODO Switch back to the router service once the service behaves more like Route + // https://github.com/emberjs/ember.js/issues/15801 // router: inject.service('router'), - _router: inject.service('-routing'), - router: computed.alias('_router.router'), + _router: service('-routing'), + router: alias('_router.router'), tagName: 'tr', classNames: ['server-agent-row', 'is-interactive'], @@ -15,7 +17,8 @@ export default Component.extend({ agent: null, isActive: computed('agent', 'router.currentURL', function() { - // TODO Switch back to the router service style when it is no longer feature-flagged + // TODO Switch back to the router service once the service behaves more like Route + // https://github.com/emberjs/ember.js/issues/15801 // const targetURL = this.get('router').urlFor('servers.server', this.get('agent')); // const currentURL = `${this.get('router.rootURL').slice(0, -1)}${this.get('router.currentURL')}`; diff --git a/ui/app/components/task-group-row.js b/ui/app/components/task-group-row.js index 4fb239637..98753d615 100644 --- a/ui/app/components/task-group-row.js +++ b/ui/app/components/task-group-row.js @@ -1,8 +1,6 @@ -import Ember from 'ember'; +import Component from '@ember/component'; import { lazyClick } from '../helpers/lazy-click'; -const { Component } = Ember; - export default Component.extend({ tagName: 'tr', diff --git a/ui/app/components/task-log.js b/ui/app/components/task-log.js index 1b066db73..277023859 100644 --- a/ui/app/components/task-log.js +++ b/ui/app/components/task-log.js @@ -1,12 +1,13 @@ -import Ember from 'ember'; +import { inject as service } from '@ember/service'; +import Component from '@ember/component'; +import { computed } from '@ember/object'; +import { run } from '@ember/runloop'; import { task } from 'ember-concurrency'; import { logger } from 'nomad-ui/utils/classes/log'; import WindowResizable from 'nomad-ui/mixins/window-resizable'; -const { Component, computed, inject, run } = Ember; - export default Component.extend(WindowResizable, { - token: inject.service(), + token: service(), classNames: ['boxed-section', 'task-log'], diff --git a/ui/app/controllers/allocations/allocation.js b/ui/app/controllers/allocations/allocation.js index 1ee88420b..75ceaaec3 100644 --- a/ui/app/controllers/allocations/allocation.js +++ b/ui/app/controllers/allocations/allocation.js @@ -1,5 +1,3 @@ -import Ember from 'ember'; - -const { Controller } = Ember; +import Controller from '@ember/controller'; export default Controller.extend({}); diff --git a/ui/app/controllers/allocations/allocation/index.js b/ui/app/controllers/allocations/allocation/index.js index 5a764f2a6..810d0f728 100644 --- a/ui/app/controllers/allocations/allocation/index.js +++ b/ui/app/controllers/allocations/allocation/index.js @@ -1,8 +1,7 @@ -import Ember from 'ember'; +import { alias } from '@ember/object/computed'; +import Controller from '@ember/controller'; import Sortable from 'nomad-ui/mixins/sortable'; -const { Controller, computed } = Ember; - export default Controller.extend(Sortable, { queryParams: { sortProperty: 'sort', @@ -12,6 +11,6 @@ export default Controller.extend(Sortable, { sortProperty: 'name', sortDescending: false, - listToSort: computed.alias('model.states'), - sortedStates: computed.alias('listSorted'), + listToSort: alias('model.states'), + sortedStates: alias('listSorted'), }); diff --git a/ui/app/controllers/allocations/allocation/task/index.js b/ui/app/controllers/allocations/allocation/task/index.js index 7374af8fd..790e190ec 100644 --- a/ui/app/controllers/allocations/allocation/task/index.js +++ b/ui/app/controllers/allocations/allocation/task/index.js @@ -1,9 +1,9 @@ -import Ember from 'ember'; - -const { Controller, computed } = Ember; +import { alias } from '@ember/object/computed'; +import Controller from '@ember/controller'; +import { computed } from '@ember/object'; export default Controller.extend({ - network: computed.alias('model.resources.networks.firstObject'), + network: alias('model.resources.networks.firstObject'), ports: computed('network.reservedPorts.[]', 'network.dynamicPorts.[]', function() { return (this.get('network.reservedPorts') || []) .map(port => ({ diff --git a/ui/app/controllers/application.js b/ui/app/controllers/application.js index ee8bee9ee..fc3c5e58e 100644 --- a/ui/app/controllers/application.js +++ b/ui/app/controllers/application.js @@ -1,10 +1,12 @@ +import { inject as service } from '@ember/service'; +import Controller from '@ember/controller'; +import { run } from '@ember/runloop'; +import { observer, computed } from '@ember/object'; import Ember from 'ember'; import codesForError from '../utils/codes-for-error'; -const { Controller, computed, inject, run, observer } = Ember; - export default Controller.extend({ - config: inject.service(), + config: service(), error: null, @@ -33,7 +35,7 @@ export default Controller.extend({ run.next(() => { throw this.get('error'); }); - } else { + } else if (!Ember.testing) { run.next(() => { // eslint-disable-next-line console.warn('UNRECOVERABLE ERROR:', this.get('error')); diff --git a/ui/app/controllers/clients.js b/ui/app/controllers/clients.js index 41e5c9e2f..f4d0631dc 100644 --- a/ui/app/controllers/clients.js +++ b/ui/app/controllers/clients.js @@ -1,6 +1,4 @@ -import Ember from 'ember'; - -const { Controller } = Ember; +import Controller from '@ember/controller'; export default Controller.extend({ isForbidden: false, diff --git a/ui/app/controllers/clients/client.js b/ui/app/controllers/clients/client.js index ed463e813..008169f5e 100644 --- a/ui/app/controllers/clients/client.js +++ b/ui/app/controllers/clients/client.js @@ -1,9 +1,9 @@ -import Ember from 'ember'; +import { alias } from '@ember/object/computed'; +import Controller from '@ember/controller'; +import { computed } from '@ember/object'; import Sortable from 'nomad-ui/mixins/sortable'; import Searchable from 'nomad-ui/mixins/searchable'; -const { Controller, computed } = Ember; - export default Controller.extend(Sortable, Searchable, { queryParams: { currentPage: 'page', @@ -20,9 +20,9 @@ export default Controller.extend(Sortable, Searchable, { searchProps: computed(() => ['shortId', 'name']), - listToSort: computed.alias('model.allocations'), - listToSearch: computed.alias('listSorted'), - sortedAllocations: computed.alias('listSearched'), + listToSort: alias('model.allocations'), + listToSearch: alias('listSorted'), + sortedAllocations: alias('listSearched'), actions: { gotoAllocation(allocation) { diff --git a/ui/app/controllers/clients/index.js b/ui/app/controllers/clients/index.js index eedb9ebe8..ac19f9d9d 100644 --- a/ui/app/controllers/clients/index.js +++ b/ui/app/controllers/clients/index.js @@ -1,14 +1,14 @@ -import Ember from 'ember'; +import { alias } from '@ember/object/computed'; +import Controller, { inject as controller } from '@ember/controller'; +import { computed } from '@ember/object'; import Sortable from 'nomad-ui/mixins/sortable'; import Searchable from 'nomad-ui/mixins/searchable'; -const { Controller, computed, inject } = Ember; - export default Controller.extend(Sortable, Searchable, { - clientsController: inject.controller('clients'), + clientsController: controller('clients'), - nodes: computed.alias('model.nodes'), - agents: computed.alias('model.agents'), + nodes: alias('model.nodes'), + agents: alias('model.agents'), queryParams: { currentPage: 'page', @@ -25,11 +25,11 @@ export default Controller.extend(Sortable, Searchable, { searchProps: computed(() => ['id', 'name', 'datacenter']), - listToSort: computed.alias('nodes'), - listToSearch: computed.alias('listSorted'), - sortedNodes: computed.alias('listSearched'), + listToSort: alias('nodes'), + listToSearch: alias('listSorted'), + sortedNodes: alias('listSearched'), - isForbidden: computed.alias('clientsController.isForbidden'), + isForbidden: alias('clientsController.isForbidden'), actions: { gotoNode(node) { diff --git a/ui/app/controllers/freestyle.js b/ui/app/controllers/freestyle.js index 71e5bde38..a5809f613 100644 --- a/ui/app/controllers/freestyle.js +++ b/ui/app/controllers/freestyle.js @@ -1,47 +1,6 @@ -import Ember from 'ember'; +import { inject as service } from '@ember/service'; import FreestyleController from 'ember-freestyle/controllers/freestyle'; -const { inject, computed } = Ember; - export default FreestyleController.extend({ - emberFreestyle: inject.service(), - - timerTicks: 0, - - startTimer: function() { - this.set( - 'timer', - setInterval(() => { - this.incrementProperty('timerTicks'); - }, 500) - ); - }.on('init'), - - stopTimer: function() { - clearInterval(this.get('timer')); - }.on('willDestroy'), - - distributionBarData: computed(() => { - return [ - { label: 'one', value: 10 }, - { label: 'two', value: 20 }, - { label: 'three', value: 30 }, - ]; - }), - - distributionBarDataWithClasses: computed(() => { - return [ - { label: 'Queued', value: 10, className: 'queued' }, - { label: 'Complete', value: 20, className: 'complete' }, - { label: 'Failed', value: 30, className: 'failed' }, - ]; - }), - - distributionBarDataRotating: computed('timerTicks', () => { - return [ - { label: 'one', value: Math.round(Math.random() * 50) }, - { label: 'two', value: Math.round(Math.random() * 50) }, - { label: 'three', value: Math.round(Math.random() * 50) }, - ]; - }), + emberFreestyle: service(), }); diff --git a/ui/app/controllers/jobs.js b/ui/app/controllers/jobs.js index 88f3d60e1..e7a69a7d8 100644 --- a/ui/app/controllers/jobs.js +++ b/ui/app/controllers/jobs.js @@ -1,9 +1,10 @@ -import Ember from 'ember'; - -const { Controller, inject, observer, run } = Ember; +import { inject as service } from '@ember/service'; +import Controller from '@ember/controller'; +import { observer } from '@ember/object'; +import { run } from '@ember/runloop'; export default Controller.extend({ - system: inject.service(), + system: service(), queryParams: { jobNamespace: 'namespace', diff --git a/ui/app/controllers/jobs/index.js b/ui/app/controllers/jobs/index.js index 079d30dc3..d0d06e9ee 100644 --- a/ui/app/controllers/jobs/index.js +++ b/ui/app/controllers/jobs/index.js @@ -1,18 +1,19 @@ -import Ember from 'ember'; +import { inject as service } from '@ember/service'; +import { alias, filterBy } from '@ember/object/computed'; +import Controller, { inject as controller } from '@ember/controller'; +import { computed } from '@ember/object'; import Sortable from 'nomad-ui/mixins/sortable'; import Searchable from 'nomad-ui/mixins/searchable'; -const { Controller, computed, inject } = Ember; - export default Controller.extend(Sortable, Searchable, { - system: inject.service(), - jobsController: inject.controller('jobs'), + system: service(), + jobsController: controller('jobs'), - isForbidden: computed.alias('jobsController.isForbidden'), + isForbidden: alias('jobsController.isForbidden'), - pendingJobs: computed.filterBy('model', 'status', 'pending'), - runningJobs: computed.filterBy('model', 'status', 'running'), - deadJobs: computed.filterBy('model', 'status', 'dead'), + pendingJobs: filterBy('model', 'status', 'pending'), + runningJobs: filterBy('model', 'status', 'running'), + deadJobs: filterBy('model', 'status', 'dead'), queryParams: { currentPage: 'page', @@ -42,9 +43,9 @@ export default Controller.extend(Sortable, Searchable, { } ), - listToSort: computed.alias('filteredJobs'), - listToSearch: computed.alias('listSorted'), - sortedJobs: computed.alias('listSearched'), + listToSort: alias('filteredJobs'), + listToSearch: alias('listSorted'), + sortedJobs: alias('listSearched'), isShowingDeploymentDetails: false, diff --git a/ui/app/controllers/jobs/job.js b/ui/app/controllers/jobs/job.js index fab3c3204..5b8865a11 100644 --- a/ui/app/controllers/jobs/job.js +++ b/ui/app/controllers/jobs/job.js @@ -1,6 +1,5 @@ -import Ember from 'ember'; - -const { Controller, computed } = Ember; +import Controller from '@ember/controller'; +import { computed } from '@ember/object'; export default Controller.extend({ breadcrumbs: computed('model.{name,id}', function() { diff --git a/ui/app/controllers/jobs/job/definition.js b/ui/app/controllers/jobs/job/definition.js index 95144a831..b105b72b0 100644 --- a/ui/app/controllers/jobs/job/definition.js +++ b/ui/app/controllers/jobs/job/definition.js @@ -1,12 +1,11 @@ -import Ember from 'ember'; +import { alias } from '@ember/object/computed'; +import Controller, { inject as controller } from '@ember/controller'; import WithNamespaceResetting from 'nomad-ui/mixins/with-namespace-resetting'; -const { Controller, computed, inject } = Ember; - export default Controller.extend(WithNamespaceResetting, { - jobController: inject.controller('jobs.job'), + jobController: controller('jobs.job'), - job: computed.alias('model.job'), + job: alias('model.job'), - breadcrumbs: computed.alias('jobController.breadcrumbs'), + breadcrumbs: alias('jobController.breadcrumbs'), }); diff --git a/ui/app/controllers/jobs/job/deployments.js b/ui/app/controllers/jobs/job/deployments.js index a8c6b1761..0540c98b1 100644 --- a/ui/app/controllers/jobs/job/deployments.js +++ b/ui/app/controllers/jobs/job/deployments.js @@ -1,13 +1,12 @@ -import Ember from 'ember'; +import { alias } from '@ember/object/computed'; +import Controller, { inject as controller } from '@ember/controller'; import WithNamespaceResetting from 'nomad-ui/mixins/with-namespace-resetting'; -const { Controller, computed, inject } = Ember; - export default Controller.extend(WithNamespaceResetting, { - jobController: inject.controller('jobs.job'), + jobController: controller('jobs.job'), - job: computed.alias('model'), - deployments: computed.alias('model.deployments'), + job: alias('model'), + deployments: alias('model.deployments'), - breadcrumbs: computed.alias('jobController.breadcrumbs'), + breadcrumbs: alias('jobController.breadcrumbs'), }); diff --git a/ui/app/controllers/jobs/job/index.js b/ui/app/controllers/jobs/job/index.js index 2738cccf1..97b97efb5 100644 --- a/ui/app/controllers/jobs/job/index.js +++ b/ui/app/controllers/jobs/job/index.js @@ -1,12 +1,13 @@ -import Ember from 'ember'; +import { inject as service } from '@ember/service'; +import { alias } from '@ember/object/computed'; +import Controller, { inject as controller } from '@ember/controller'; +import { computed } from '@ember/object'; import Sortable from 'nomad-ui/mixins/sortable'; import WithNamespaceResetting from 'nomad-ui/mixins/with-namespace-resetting'; -const { Controller, computed, inject } = Ember; - export default Controller.extend(Sortable, WithNamespaceResetting, { - system: inject.service(), - jobController: inject.controller('jobs.job'), + system: service(), + jobController: controller('jobs.job'), queryParams: { currentPage: 'page', @@ -20,15 +21,15 @@ export default Controller.extend(Sortable, WithNamespaceResetting, { sortProperty: 'name', sortDescending: false, - breadcrumbs: computed.alias('jobController.breadcrumbs'), - job: computed.alias('model'), + breadcrumbs: alias('jobController.breadcrumbs'), + job: alias('model'), taskGroups: computed('model.taskGroups.[]', function() { return this.get('model.taskGroups') || []; }), - listToSort: computed.alias('taskGroups'), - sortedTaskGroups: computed.alias('listSorted'), + listToSort: alias('taskGroups'), + sortedTaskGroups: alias('listSorted'), sortedEvaluations: computed('model.evaluations.@each.modifyIndex', function() { return (this.get('model.evaluations') || []).sortBy('modifyIndex').reverse(); diff --git a/ui/app/controllers/jobs/job/loading.js b/ui/app/controllers/jobs/job/loading.js index bb159e836..2251e2d75 100644 --- a/ui/app/controllers/jobs/job/loading.js +++ b/ui/app/controllers/jobs/job/loading.js @@ -1,8 +1,7 @@ -import Ember from 'ember'; - -const { Controller, computed, inject } = Ember; +import { alias } from '@ember/object/computed'; +import Controller, { inject as controller } from '@ember/controller'; export default Controller.extend({ - jobController: inject.controller('jobs.job'), - breadcrumbs: computed.alias('jobController.breadcrumbs'), + jobController: controller('jobs.job'), + breadcrumbs: alias('jobController.breadcrumbs'), }); diff --git a/ui/app/controllers/jobs/job/task-group.js b/ui/app/controllers/jobs/job/task-group.js index 4fc628201..2e2ab25e7 100644 --- a/ui/app/controllers/jobs/job/task-group.js +++ b/ui/app/controllers/jobs/job/task-group.js @@ -1,12 +1,12 @@ -import Ember from 'ember'; +import { alias } from '@ember/object/computed'; +import Controller, { inject as controller } from '@ember/controller'; +import { computed } from '@ember/object'; import Sortable from 'nomad-ui/mixins/sortable'; import Searchable from 'nomad-ui/mixins/searchable'; import WithNamespaceResetting from 'nomad-ui/mixins/with-namespace-resetting'; -const { Controller, computed, inject } = Ember; - export default Controller.extend(Sortable, Searchable, WithNamespaceResetting, { - jobController: inject.controller('jobs.job'), + jobController: controller('jobs.job'), queryParams: { currentPage: 'page', @@ -27,9 +27,9 @@ export default Controller.extend(Sortable, Searchable, WithNamespaceResetting, { return this.get('model.allocations') || []; }), - listToSort: computed.alias('allocations'), - listToSearch: computed.alias('listSorted'), - sortedAllocations: computed.alias('listSearched'), + listToSort: alias('allocations'), + listToSearch: alias('listSorted'), + sortedAllocations: alias('listSearched'), breadcrumbs: computed('jobController.breadcrumbs.[]', 'model.{name}', function() { return this.get('jobController.breadcrumbs').concat([ diff --git a/ui/app/controllers/jobs/job/versions.js b/ui/app/controllers/jobs/job/versions.js index 3b2420213..eb669a22b 100644 --- a/ui/app/controllers/jobs/job/versions.js +++ b/ui/app/controllers/jobs/job/versions.js @@ -1,13 +1,12 @@ -import Ember from 'ember'; +import { alias } from '@ember/object/computed'; +import Controller, { inject as controller } from '@ember/controller'; import WithNamespaceResetting from 'nomad-ui/mixins/with-namespace-resetting'; -const { Controller, computed, inject } = Ember; - export default Controller.extend(WithNamespaceResetting, { - jobController: inject.controller('jobs.job'), + jobController: controller('jobs.job'), - job: computed.alias('model'), - versions: computed.alias('model.versions'), + job: alias('model'), + versions: alias('model.versions'), - breadcrumbs: computed.alias('jobController.breadcrumbs'), + breadcrumbs: alias('jobController.breadcrumbs'), }); diff --git a/ui/app/controllers/servers.js b/ui/app/controllers/servers.js index a8d0e1f8a..5e6f1bea4 100644 --- a/ui/app/controllers/servers.js +++ b/ui/app/controllers/servers.js @@ -1,11 +1,10 @@ -import Ember from 'ember'; +import { alias } from '@ember/object/computed'; +import Controller from '@ember/controller'; import Sortable from 'nomad-ui/mixins/sortable'; -const { Controller, computed } = Ember; - export default Controller.extend(Sortable, { - nodes: computed.alias('model.nodes'), - agents: computed.alias('model.agents'), + nodes: alias('model.nodes'), + agents: alias('model.agents'), queryParams: { currentPage: 'page', @@ -21,6 +20,6 @@ export default Controller.extend(Sortable, { isForbidden: false, - listToSort: computed.alias('agents'), - sortedAgents: computed.alias('listSorted'), + listToSort: alias('agents'), + sortedAgents: alias('listSorted'), }); diff --git a/ui/app/controllers/servers/index.js b/ui/app/controllers/servers/index.js index adf0eee4d..ee49e8f72 100644 --- a/ui/app/controllers/servers/index.js +++ b/ui/app/controllers/servers/index.js @@ -1,8 +1,7 @@ -import Ember from 'ember'; - -const { Controller, computed, inject } = Ember; +import { alias } from '@ember/object/computed'; +import Controller, { inject as controller } from '@ember/controller'; export default Controller.extend({ - serversController: inject.controller('servers'), - isForbidden: computed.alias('serversController.isForbidden'), + serversController: controller('servers'), + isForbidden: alias('serversController.isForbidden'), }); diff --git a/ui/app/controllers/servers/server.js b/ui/app/controllers/servers/server.js index 94f5f0bdd..dd455b3ad 100644 --- a/ui/app/controllers/servers/server.js +++ b/ui/app/controllers/servers/server.js @@ -1,6 +1,5 @@ -import Ember from 'ember'; - -const { Controller, computed } = Ember; +import Controller from '@ember/controller'; +import { computed } from '@ember/object'; export default Controller.extend({ activeTab: 'tags', diff --git a/ui/app/controllers/settings/tokens.js b/ui/app/controllers/settings/tokens.js index 2aad23469..be1db757d 100644 --- a/ui/app/controllers/settings/tokens.js +++ b/ui/app/controllers/settings/tokens.js @@ -1,12 +1,13 @@ -import Ember from 'ember'; - -const { Controller, inject, computed, getOwner } = Ember; +import { inject as service } from '@ember/service'; +import { reads } from '@ember/object/computed'; +import Controller from '@ember/controller'; +import { getOwner } from '@ember/application'; export default Controller.extend({ - token: inject.service(), - store: inject.service(), + token: service(), + store: service(), - secret: computed.reads('token.secret'), + secret: reads('token.secret'), tokenIsValid: false, tokenIsInvalid: false, diff --git a/ui/app/helpers/css-class.js b/ui/app/helpers/css-class.js index a23d3b90d..d7f30127c 100644 --- a/ui/app/helpers/css-class.js +++ b/ui/app/helpers/css-class.js @@ -1,4 +1,4 @@ -import Ember from 'ember'; +import { helper } from '@ember/component/helper'; /** * CSS Class @@ -12,4 +12,4 @@ export function cssClass([updateType]) { return updateType.replace(/\//g, '-').dasherize(); } -export default Ember.Helper.helper(cssClass); +export default helper(cssClass); diff --git a/ui/app/helpers/format-bytes.js b/ui/app/helpers/format-bytes.js index dc3ab3e15..b2c69ed06 100644 --- a/ui/app/helpers/format-bytes.js +++ b/ui/app/helpers/format-bytes.js @@ -1,6 +1,4 @@ -import Ember from 'ember'; - -const { Helper } = Ember; +import Helper from '@ember/component/helper'; const UNITS = ['Bytes', 'KiB', 'MiB']; diff --git a/ui/app/helpers/format-percentage.js b/ui/app/helpers/format-percentage.js index ff4358bc7..cfd409c13 100644 --- a/ui/app/helpers/format-percentage.js +++ b/ui/app/helpers/format-percentage.js @@ -1,4 +1,4 @@ -import Ember from 'ember'; +import { helper } from '@ember/component/helper'; /** * Percentage Calculator @@ -35,4 +35,4 @@ function safeNumber(value) { return isNaN(value) ? 0 : +value; } -export default Ember.Helper.helper(formatPercentage); +export default helper(formatPercentage); diff --git a/ui/app/helpers/is-object.js b/ui/app/helpers/is-object.js index 0b7dae86d..97dd42e65 100644 --- a/ui/app/helpers/is-object.js +++ b/ui/app/helpers/is-object.js @@ -1,6 +1,4 @@ -import Ember from 'ember'; - -const { Helper } = Ember; +import Helper from '@ember/component/helper'; export function isObject([value]) { const isObject = !Array.isArray(value) && value !== null && typeof value === 'object'; diff --git a/ui/app/helpers/lazy-click.js b/ui/app/helpers/lazy-click.js index f9d12dcd2..de96d1c0e 100644 --- a/ui/app/helpers/lazy-click.js +++ b/ui/app/helpers/lazy-click.js @@ -1,6 +1,5 @@ -import Ember from 'ember'; - -const { Helper, $ } = Ember; +import Helper from '@ember/component/helper'; +import $ from 'jquery'; /** * Lazy Click Event diff --git a/ui/app/helpers/pluralize.js b/ui/app/helpers/pluralize.js index 161b366a4..fcab84cbe 100644 --- a/ui/app/helpers/pluralize.js +++ b/ui/app/helpers/pluralize.js @@ -1,6 +1,4 @@ -import Ember from 'ember'; - -const { Helper } = Ember; +import Helper from '@ember/component/helper'; export function pluralize([term, count]) { return count === 1 ? term : term.pluralize(); diff --git a/ui/app/helpers/x-icon.js b/ui/app/helpers/x-icon.js index d3c670e54..f53a50543 100644 --- a/ui/app/helpers/x-icon.js +++ b/ui/app/helpers/x-icon.js @@ -1,4 +1,4 @@ -import Ember from 'ember'; +import { helper } from '@ember/component/helper'; import { inlineSvg } from 'ember-inline-svg/helpers/inline-svg'; // Generated at compile-time by ember-inline-svg @@ -18,4 +18,4 @@ export function xIcon(params, options) { return inlineSvg(SVGs, name, { class: classes }); } -export default Ember.Helper.helper(xIcon); +export default helper(xIcon); diff --git a/ui/app/mixins/searchable.js b/ui/app/mixins/searchable.js index 99a929eed..26557f75a 100644 --- a/ui/app/mixins/searchable.js +++ b/ui/app/mixins/searchable.js @@ -1,6 +1,5 @@ -import Ember from 'ember'; - -const { Mixin, computed, get } = Ember; +import Mixin from '@ember/object/mixin'; +import { get, computed } from '@ember/object'; /** Searchable mixin diff --git a/ui/app/mixins/sortable.js b/ui/app/mixins/sortable.js index 269075cd8..e6a78aaf9 100644 --- a/ui/app/mixins/sortable.js +++ b/ui/app/mixins/sortable.js @@ -1,6 +1,5 @@ -import Ember from 'ember'; - -const { Mixin, computed } = Ember; +import Mixin from '@ember/object/mixin'; +import { computed } from '@ember/object'; /** Sortable mixin diff --git a/ui/app/mixins/window-resizable.js b/ui/app/mixins/window-resizable.js index 3f3c5b7aa..6a7055bdf 100644 --- a/ui/app/mixins/window-resizable.js +++ b/ui/app/mixins/window-resizable.js @@ -1,8 +1,8 @@ -import Ember from 'ember'; +import Mixin from '@ember/object/mixin'; +import { run } from '@ember/runloop'; +import $ from 'jquery'; -const { run, $ } = Ember; - -export default Ember.Mixin.create({ +export default Mixin.create({ setupWindowResize: function() { run.scheduleOnce('afterRender', this, () => { this.set('_windowResizeHandler', this.get('windowResizeHandler').bind(this)); diff --git a/ui/app/mixins/with-forbidden-state.js b/ui/app/mixins/with-forbidden-state.js index 4b1397e09..85f27676a 100644 --- a/ui/app/mixins/with-forbidden-state.js +++ b/ui/app/mixins/with-forbidden-state.js @@ -1,6 +1,4 @@ -import Ember from 'ember'; - -const { Mixin } = Ember; +import Mixin from '@ember/object/mixin'; export default Mixin.create({ setupController(controller) { diff --git a/ui/app/mixins/with-model-error-handling.js b/ui/app/mixins/with-model-error-handling.js index 585c61595..484a80148 100644 --- a/ui/app/mixins/with-model-error-handling.js +++ b/ui/app/mixins/with-model-error-handling.js @@ -1,8 +1,6 @@ -import Ember from 'ember'; +import Mixin from '@ember/object/mixin'; import notifyError from 'nomad-ui/utils/notify-error'; -const { Mixin } = Ember; - export default Mixin.create({ model() { return this._super(...arguments).catch(notifyError(this)); diff --git a/ui/app/mixins/with-namespace-resetting.js b/ui/app/mixins/with-namespace-resetting.js index c40407568..ab57f3e3e 100644 --- a/ui/app/mixins/with-namespace-resetting.js +++ b/ui/app/mixins/with-namespace-resetting.js @@ -1,10 +1,10 @@ -import Ember from 'ember'; - -const { Mixin, inject } = Ember; +import { inject as controller } from '@ember/controller'; +import { inject as service } from '@ember/service'; +import Mixin from '@ember/object/mixin'; export default Mixin.create({ - system: inject.service(), - jobsController: inject.controller('jobs'), + system: service(), + jobsController: controller('jobs'), actions: { gotoJobs(namespace) { diff --git a/ui/app/models/agent.js b/ui/app/models/agent.js index aea6c7ee5..63bd7d2b0 100644 --- a/ui/app/models/agent.js +++ b/ui/app/models/agent.js @@ -1,11 +1,10 @@ -import Ember from 'ember'; +import { inject as service } from '@ember/service'; +import { computed } from '@ember/object'; import Model from 'ember-data/model'; import attr from 'ember-data/attr'; -const { computed, inject } = Ember; - export default Model.extend({ - system: inject.service(), + system: service(), name: attr('string'), address: attr('string'), diff --git a/ui/app/models/allocation.js b/ui/app/models/allocation.js index 6cf164a41..617c1d3f7 100644 --- a/ui/app/models/allocation.js +++ b/ui/app/models/allocation.js @@ -1,4 +1,7 @@ -import Ember from 'ember'; +import { inject as service } from '@ember/service'; +import { readOnly } from '@ember/object/computed'; +import { computed } from '@ember/object'; +import RSVP from 'rsvp'; import Model from 'ember-data/model'; import attr from 'ember-data/attr'; import { belongsTo } from 'ember-data/relationships'; @@ -7,8 +10,6 @@ import PromiseObject from '../utils/classes/promise-object'; import timeout from '../utils/timeout'; import shortUUIDProperty from '../utils/properties/short-uuid'; -const { computed, RSVP, inject } = Ember; - const STATUS_ORDER = { pending: 1, running: 2, @@ -18,7 +19,7 @@ const STATUS_ORDER = { }; export default Model.extend({ - token: inject.service(), + token: service(), shortId: shortUUIDProperty('id'), job: belongsTo('job'), @@ -56,7 +57,7 @@ export default Model.extend({ return taskGroups && taskGroups.findBy('name', this.get('taskGroupName')); }), - memoryUsed: computed.readOnly('stats.ResourceUsage.MemoryStats.RSS'), + memoryUsed: readOnly('stats.ResourceUsage.MemoryStats.RSS'), cpuUsed: computed('stats.ResourceUsage.CpuStats.TotalTicks', function() { return Math.floor(this.get('stats.ResourceUsage.CpuStats.TotalTicks') || 0); }), diff --git a/ui/app/models/deployment.js b/ui/app/models/deployment.js index 2a952afb7..02dfb4c81 100644 --- a/ui/app/models/deployment.js +++ b/ui/app/models/deployment.js @@ -1,4 +1,5 @@ -import Ember from 'ember'; +import { alias } from '@ember/object/computed'; +import { computed } from '@ember/object'; import Model from 'ember-data/model'; import attr from 'ember-data/attr'; import { belongsTo, hasMany } from 'ember-data/relationships'; @@ -6,8 +7,6 @@ import { fragmentArray } from 'ember-data-model-fragments/attributes'; import shortUUIDProperty from '../utils/properties/short-uuid'; import sumAggregation from '../utils/properties/sum-aggregation'; -const { computed } = Ember; - export default Model.extend({ shortId: shortUUIDProperty('id'), @@ -34,6 +33,9 @@ export default Model.extend({ return (this.get('job.versions') || []).findBy('number', this.get('versionNumber')); }), + // Dependent keys can only go one level past an @each so an alias is needed + versionSubmitTime: alias('version.submitTime'), + placedCanaries: sumAggregation('taskGroupSummaries', 'placedCanaries'), desiredCanaries: sumAggregation('taskGroupSummaries', 'desiredCanaries'), desiredTotal: sumAggregation('taskGroupSummaries', 'desiredTotal'), diff --git a/ui/app/models/evaluation.js b/ui/app/models/evaluation.js index 8afdf15ae..5fcb3550a 100644 --- a/ui/app/models/evaluation.js +++ b/ui/app/models/evaluation.js @@ -1,12 +1,10 @@ -import Ember from 'ember'; +import { bool } from '@ember/object/computed'; import Model from 'ember-data/model'; import attr from 'ember-data/attr'; import { belongsTo } from 'ember-data/relationships'; import { fragmentArray } from 'ember-data-model-fragments/attributes'; import shortUUIDProperty from '../utils/properties/short-uuid'; -const { computed } = Ember; - export default Model.extend({ shortId: shortUUIDProperty('id'), priority: attr('number'), @@ -16,7 +14,7 @@ export default Model.extend({ statusDescription: attr('string'), failedTGAllocs: fragmentArray('placement-failure', { defaultValue: () => [] }), - hasPlacementFailures: computed.bool('failedTGAllocs.length'), + hasPlacementFailures: bool('failedTGAllocs.length'), // TEMPORARY: https://github.com/emberjs/data/issues/5209 originalJobId: attr('string'), diff --git a/ui/app/models/job.js b/ui/app/models/job.js index 4f33983ac..511f89856 100644 --- a/ui/app/models/job.js +++ b/ui/app/models/job.js @@ -1,12 +1,11 @@ -import Ember from 'ember'; +import { collect, sum, bool, equal } from '@ember/object/computed'; +import { computed } from '@ember/object'; import Model from 'ember-data/model'; import attr from 'ember-data/attr'; import { belongsTo, hasMany } from 'ember-data/relationships'; import { fragmentArray } from 'ember-data-model-fragments/attributes'; import sumAggregation from '../utils/properties/sum-aggregation'; -const { computed } = Ember; - export default Model.extend({ region: attr('string'), name: attr('string'), @@ -35,7 +34,7 @@ export default Model.extend({ failedAllocs: sumAggregation('taskGroupSummaries', 'failedAllocs'), lostAllocs: sumAggregation('taskGroupSummaries', 'lostAllocs'), - allocsList: computed.collect( + allocsList: collect( 'queuedAllocs', 'startingAllocs', 'runningAllocs', @@ -44,7 +43,7 @@ export default Model.extend({ 'lostAllocs' ), - totalAllocs: computed.sum('allocsList'), + totalAllocs: sum('allocsList'), pendingChildren: attr('number'), runningChildren: attr('number'), @@ -56,7 +55,7 @@ export default Model.extend({ evaluations: hasMany('evaluations'), namespace: belongsTo('namespace'), - hasPlacementFailures: computed.bool('latestFailureEvaluation'), + hasPlacementFailures: bool('latestFailureEvaluation'), latestEvaluation: computed('evaluations.@each.modifyIndex', 'evaluations.isPending', function() { const evaluations = this.get('evaluations'); @@ -82,7 +81,7 @@ export default Model.extend({ } ), - supportsDeployments: computed.equal('type', 'service'), + supportsDeployments: equal('type', 'service'), runningDeployment: computed('deployments.@each.status', function() { return this.get('deployments').findBy('status', 'running'); diff --git a/ui/app/models/namespace.js b/ui/app/models/namespace.js index 7b05135e8..b5b5f5750 100644 --- a/ui/app/models/namespace.js +++ b/ui/app/models/namespace.js @@ -1,11 +1,9 @@ -import Ember from 'ember'; +import { readOnly } from '@ember/object/computed'; import Model from 'ember-data/model'; import attr from 'ember-data/attr'; -const { computed } = Ember; - export default Model.extend({ - name: computed.readOnly('id'), + name: readOnly('id'), hash: attr('string'), description: attr('string'), }); diff --git a/ui/app/models/node-attributes.js b/ui/app/models/node-attributes.js index 6bfaba7ca..c893d8500 100644 --- a/ui/app/models/node-attributes.js +++ b/ui/app/models/node-attributes.js @@ -1,9 +1,8 @@ -import Ember from 'ember'; +import { get, computed } from '@ember/object'; import attr from 'ember-data/attr'; import Fragment from 'ember-data-model-fragments/fragment'; import flat from 'npm:flat'; -const { computed, get } = Ember; const { unflatten } = flat; export default Fragment.extend({ @@ -12,10 +11,12 @@ export default Fragment.extend({ attributesStructured: computed('attributes', function() { // `unflatten` doesn't sort keys before unflattening, so manual preprocessing is necessary. const original = this.get('attributes'); - const attrs = Object.keys(original).sort().reduce((obj, key) => { - obj[key] = original[key]; - return obj; - }, {}); + const attrs = Object.keys(original) + .sort() + .reduce((obj, key) => { + obj[key] = original[key]; + return obj; + }, {}); return unflatten(attrs, { overwrite: true }); }), diff --git a/ui/app/models/node.js b/ui/app/models/node.js index 6966acfb9..56e489e1d 100644 --- a/ui/app/models/node.js +++ b/ui/app/models/node.js @@ -1,4 +1,4 @@ -import Ember from 'ember'; +import { computed } from '@ember/object'; import Model from 'ember-data/model'; import attr from 'ember-data/attr'; import { hasMany } from 'ember-data/relationships'; @@ -6,8 +6,6 @@ import { fragment } from 'ember-data-model-fragments/attributes'; import shortUUIDProperty from '../utils/properties/short-uuid'; import ipParts from '../utils/ip-parts'; -const { computed } = Ember; - export default Model.extend({ // Available from list response name: attr('string'), diff --git a/ui/app/models/task-event.js b/ui/app/models/task-event.js index 1a26daadc..3d6f464f3 100644 --- a/ui/app/models/task-event.js +++ b/ui/app/models/task-event.js @@ -1,10 +1,9 @@ -import Ember from 'ember'; +import { computed } from '@ember/object'; import Fragment from 'ember-data-model-fragments/fragment'; import attr from 'ember-data/attr'; import { fragmentOwner } from 'ember-data-model-fragments/attributes'; import moment from 'moment'; -const { computed } = Ember; const displayProps = [ 'message', 'validationError', diff --git a/ui/app/models/task-group-deployment-summary.js b/ui/app/models/task-group-deployment-summary.js index 9a6b0eab3..9d5c04035 100644 --- a/ui/app/models/task-group-deployment-summary.js +++ b/ui/app/models/task-group-deployment-summary.js @@ -1,10 +1,8 @@ -import Ember from 'ember'; +import { gt } 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'; -const { computed } = Ember; - export default Fragment.extend({ deployment: fragmentOwner(), @@ -12,7 +10,7 @@ export default Fragment.extend({ autoRevert: attr('boolean'), promoted: attr('boolean'), - requiresPromotion: computed.gt('desiredCanaries', 0), + requiresPromotion: gt('desiredCanaries', 0), placedCanaries: attr('number'), desiredCanaries: attr('number'), diff --git a/ui/app/models/task-group-summary.js b/ui/app/models/task-group-summary.js index 5d937f73b..9e713b8ab 100644 --- a/ui/app/models/task-group-summary.js +++ b/ui/app/models/task-group-summary.js @@ -1,10 +1,8 @@ -import Ember from 'ember'; +import { collect, sum } 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'; -const { computed } = Ember; - export default Fragment.extend({ job: fragmentOwner(), name: attr('string'), @@ -16,7 +14,7 @@ export default Fragment.extend({ failedAllocs: attr('number'), lostAllocs: attr('number'), - allocsList: computed.collect( + allocsList: collect( 'queuedAllocs', 'startingAllocs', 'runningAllocs', @@ -25,5 +23,5 @@ export default Fragment.extend({ 'lostAllocs' ), - totalAllocs: computed.sum('allocsList'), + totalAllocs: sum('allocsList'), }); diff --git a/ui/app/models/task-group.js b/ui/app/models/task-group.js index b3998a4cd..e5ea67d0e 100644 --- a/ui/app/models/task-group.js +++ b/ui/app/models/task-group.js @@ -1,11 +1,9 @@ -import Ember from 'ember'; +import { computed } from '@ember/object'; import Fragment from 'ember-data-model-fragments/fragment'; import attr from 'ember-data/attr'; import { fragmentOwner, fragmentArray } from 'ember-data-model-fragments/attributes'; import sumAggregation from '../utils/properties/sum-aggregation'; -const { computed } = Ember; - export default Fragment.extend({ job: fragmentOwner(), diff --git a/ui/app/models/task-state.js b/ui/app/models/task-state.js index a5b3bff22..217e87459 100644 --- a/ui/app/models/task-state.js +++ b/ui/app/models/task-state.js @@ -1,10 +1,9 @@ -import Ember from 'ember'; +import { none } from '@ember/object/computed'; +import { computed } from '@ember/object'; import Fragment from 'ember-data-model-fragments/fragment'; import attr from 'ember-data/attr'; import { fragment, fragmentOwner, fragmentArray } from 'ember-data-model-fragments/attributes'; -const { computed } = Ember; - export default Fragment.extend({ name: attr('string'), state: attr('string'), @@ -12,7 +11,7 @@ export default Fragment.extend({ finishedAt: attr('date'), failed: attr('boolean'), - isActive: computed.none('finishedAt'), + isActive: none('finishedAt'), allocation: fragmentOwner(), task: computed('allocation.taskGroup.tasks.[]', function() { diff --git a/ui/app/models/token.js b/ui/app/models/token.js index 07243dd8a..37db199ae 100644 --- a/ui/app/models/token.js +++ b/ui/app/models/token.js @@ -1,10 +1,8 @@ -import Ember from 'ember'; +import { alias } from '@ember/object/computed'; import Model from 'ember-data/model'; import attr from 'ember-data/attr'; import { hasMany } from 'ember-data/relationships'; -const { computed } = Ember; - export default Model.extend({ secret: attr('string'), name: attr('string'), @@ -14,5 +12,5 @@ export default Model.extend({ policies: hasMany('policy'), policyNames: attr(), - accessor: computed.alias('id'), + accessor: alias('id'), }); diff --git a/ui/app/router.js b/ui/app/router.js index bddf8813d..494a2173d 100644 --- a/ui/app/router.js +++ b/ui/app/router.js @@ -1,7 +1,7 @@ -import Ember from 'ember'; +import EmberRouter from '@ember/routing/router'; import config from './config/environment'; -const Router = Ember.Router.extend({ +const Router = EmberRouter.extend({ location: config.locationType, rootURL: config.rootURL, }); diff --git a/ui/app/routes/allocations/allocation.js b/ui/app/routes/allocations/allocation.js index 60eff663b..17aa8b10c 100644 --- a/ui/app/routes/allocations/allocation.js +++ b/ui/app/routes/allocations/allocation.js @@ -1,6 +1,4 @@ -import Ember from 'ember'; +import Route from '@ember/routing/route'; import WithModelErrorHandling from 'nomad-ui/mixins/with-model-error-handling'; -const { Route } = Ember; - export default Route.extend(WithModelErrorHandling); diff --git a/ui/app/routes/allocations/allocation/task.js b/ui/app/routes/allocations/allocation/task.js index 4a57f1ad2..dcf2bda10 100644 --- a/ui/app/routes/allocations/allocation/task.js +++ b/ui/app/routes/allocations/allocation/task.js @@ -1,9 +1,9 @@ -import Ember from 'ember'; - -const { Route, inject, Error: EmberError } = Ember; +import { inject as service } from '@ember/service'; +import Route from '@ember/routing/route'; +import EmberError from '@ember/error'; export default Route.extend({ - store: inject.service(), + store: service(), model({ name }) { const allocation = this.modelFor('allocations.allocation'); diff --git a/ui/app/routes/allocations/allocation/task/logs.js b/ui/app/routes/allocations/allocation/task/logs.js index 5e4767eb5..399258f21 100644 --- a/ui/app/routes/allocations/allocation/task/logs.js +++ b/ui/app/routes/allocations/allocation/task/logs.js @@ -1,6 +1,4 @@ -import Ember from 'ember'; - -const { Route } = Ember; +import Route from '@ember/routing/route'; export default Route.extend({ model() { diff --git a/ui/app/routes/application.js b/ui/app/routes/application.js index 0a2e57c39..f7437a18c 100644 --- a/ui/app/routes/application.js +++ b/ui/app/routes/application.js @@ -1,9 +1,8 @@ -import Ember from 'ember'; - -const { Route, inject } = Ember; +import { inject as service } from '@ember/service'; +import Route from '@ember/routing/route'; export default Route.extend({ - config: inject.service(), + config: service(), resetController(controller, isExiting) { if (isExiting) { diff --git a/ui/app/routes/clients.js b/ui/app/routes/clients.js index f2915a3a9..49559c8c9 100644 --- a/ui/app/routes/clients.js +++ b/ui/app/routes/clients.js @@ -1,12 +1,12 @@ -import Ember from 'ember'; +import { inject as service } from '@ember/service'; +import Route from '@ember/routing/route'; +import RSVP from 'rsvp'; import WithForbiddenState from 'nomad-ui/mixins/with-forbidden-state'; import notifyForbidden from 'nomad-ui/utils/notify-forbidden'; -const { Route, inject, RSVP } = Ember; - export default Route.extend(WithForbiddenState, { - store: inject.service(), - system: inject.service(), + store: service(), + system: service(), beforeModel() { return this.get('system.leader'); diff --git a/ui/app/routes/clients/client.js b/ui/app/routes/clients/client.js index 9571d975a..1be621e47 100644 --- a/ui/app/routes/clients/client.js +++ b/ui/app/routes/clients/client.js @@ -1,10 +1,9 @@ -import Ember from 'ember'; +import { inject as service } from '@ember/service'; +import Route from '@ember/routing/route'; import notifyError from 'nomad-ui/utils/notify-error'; -const { Route, inject } = Ember; - export default Route.extend({ - store: inject.service(), + store: service(), model() { return this._super(...arguments).catch(notifyError(this)); diff --git a/ui/app/routes/freestyle.js b/ui/app/routes/freestyle.js new file mode 100644 index 000000000..21534ffe7 --- /dev/null +++ b/ui/app/routes/freestyle.js @@ -0,0 +1,18 @@ +import Route from '@ember/routing/route'; +import { inject as service } from '@ember/service'; +import RSVP from 'rsvp'; + +export default Route.extend({ + emberFreestyle: service(), + + beforeModel() { + let emberFreestyle = this.get('emberFreestyle'); + + return emberFreestyle.ensureHljs().then(() => { + return RSVP.all([ + emberFreestyle.ensureHljsLanguage('handlebars'), + emberFreestyle.ensureHljsLanguage('htmlbars'), + ]); + }); + }, +}); diff --git a/ui/app/routes/index.js b/ui/app/routes/index.js index f07c338c8..7de6fff38 100644 --- a/ui/app/routes/index.js +++ b/ui/app/routes/index.js @@ -1,6 +1,4 @@ -import Ember from 'ember'; - -const { Route } = Ember; +import Route from '@ember/routing/route'; export default Route.extend({ redirect() { diff --git a/ui/app/routes/jobs.js b/ui/app/routes/jobs.js index 9d51a55b6..745e326e2 100644 --- a/ui/app/routes/jobs.js +++ b/ui/app/routes/jobs.js @@ -1,12 +1,12 @@ -import Ember from 'ember'; +import { inject as service } from '@ember/service'; +import Route from '@ember/routing/route'; +import { run } from '@ember/runloop'; import WithForbiddenState from 'nomad-ui/mixins/with-forbidden-state'; import notifyForbidden from 'nomad-ui/utils/notify-forbidden'; -const { Route, inject, run } = Ember; - export default Route.extend(WithForbiddenState, { - system: inject.service(), - store: inject.service(), + system: service(), + store: service(), beforeModel() { return this.get('system.namespaces'); diff --git a/ui/app/routes/jobs/index.js b/ui/app/routes/jobs/index.js index 8962abd90..0a8317fea 100644 --- a/ui/app/routes/jobs/index.js +++ b/ui/app/routes/jobs/index.js @@ -1,6 +1,4 @@ -import Ember from 'ember'; - -const { Route } = Ember; +import Route from '@ember/routing/route'; export default Route.extend({ actions: { diff --git a/ui/app/routes/jobs/job.js b/ui/app/routes/jobs/job.js index 4317a1e35..558ea55e4 100644 --- a/ui/app/routes/jobs/job.js +++ b/ui/app/routes/jobs/job.js @@ -1,10 +1,10 @@ -import Ember from 'ember'; +import { inject as service } from '@ember/service'; +import Route from '@ember/routing/route'; +import RSVP from 'rsvp'; import notifyError from 'nomad-ui/utils/notify-error'; -const { Route, RSVP, inject } = Ember; - export default Route.extend({ - store: inject.service(), + store: service(), serialize(model) { return { job_name: model.get('plainId') }; @@ -13,7 +13,7 @@ export default Route.extend({ model(params, transition) { const namespace = transition.queryParams.namespace || this.get('system.activeNamespace.id'); const name = params.job_name; - const fullId = JSON.stringify([name, namespace]); + const fullId = JSON.stringify([name, namespace || 'default']); return this.get('store') .findRecord('job', fullId, { reload: true }) .then(job => { diff --git a/ui/app/routes/jobs/job/definition.js b/ui/app/routes/jobs/job/definition.js index 4f24dbc60..8730f83b8 100644 --- a/ui/app/routes/jobs/job/definition.js +++ b/ui/app/routes/jobs/job/definition.js @@ -1,6 +1,4 @@ -import Ember from 'ember'; - -const { Route } = Ember; +import Route from '@ember/routing/route'; export default Route.extend({ model() { diff --git a/ui/app/routes/jobs/job/deployments.js b/ui/app/routes/jobs/job/deployments.js index a048c898a..41363eff6 100644 --- a/ui/app/routes/jobs/job/deployments.js +++ b/ui/app/routes/jobs/job/deployments.js @@ -1,6 +1,5 @@ -import Ember from 'ember'; - -const { Route, RSVP } = Ember; +import Route from '@ember/routing/route'; +import RSVP from 'rsvp'; export default Route.extend({ model() { diff --git a/ui/app/routes/jobs/job/task-group.js b/ui/app/routes/jobs/job/task-group.js index 2be6107ab..def6e57ea 100644 --- a/ui/app/routes/jobs/job/task-group.js +++ b/ui/app/routes/jobs/job/task-group.js @@ -1,6 +1,4 @@ -import Ember from 'ember'; - -const { Route } = Ember; +import Route from '@ember/routing/route'; export default Route.extend({ model({ name }) { diff --git a/ui/app/routes/jobs/job/versions.js b/ui/app/routes/jobs/job/versions.js index 637d663c3..6debc85db 100644 --- a/ui/app/routes/jobs/job/versions.js +++ b/ui/app/routes/jobs/job/versions.js @@ -1,6 +1,4 @@ -import Ember from 'ember'; - -const { Route } = Ember; +import Route from '@ember/routing/route'; export default Route.extend({ model() { diff --git a/ui/app/routes/not-found.js b/ui/app/routes/not-found.js index 1974ee9ba..a7df89c01 100644 --- a/ui/app/routes/not-found.js +++ b/ui/app/routes/not-found.js @@ -1,6 +1,5 @@ -import Ember from 'ember'; - -const { Route, Error: EmberError } = Ember; +import Route from '@ember/routing/route'; +import EmberError from '@ember/error'; export default Route.extend({ model() { diff --git a/ui/app/routes/servers.js b/ui/app/routes/servers.js index f2915a3a9..49559c8c9 100644 --- a/ui/app/routes/servers.js +++ b/ui/app/routes/servers.js @@ -1,12 +1,12 @@ -import Ember from 'ember'; +import { inject as service } from '@ember/service'; +import Route from '@ember/routing/route'; +import RSVP from 'rsvp'; import WithForbiddenState from 'nomad-ui/mixins/with-forbidden-state'; import notifyForbidden from 'nomad-ui/utils/notify-forbidden'; -const { Route, inject, RSVP } = Ember; - export default Route.extend(WithForbiddenState, { - store: inject.service(), - system: inject.service(), + store: service(), + system: service(), beforeModel() { return this.get('system.leader'); diff --git a/ui/app/routes/servers/server.js b/ui/app/routes/servers/server.js index 60eff663b..17aa8b10c 100644 --- a/ui/app/routes/servers/server.js +++ b/ui/app/routes/servers/server.js @@ -1,6 +1,4 @@ -import Ember from 'ember'; +import Route from '@ember/routing/route'; import WithModelErrorHandling from 'nomad-ui/mixins/with-model-error-handling'; -const { Route } = Ember; - export default Route.extend(WithModelErrorHandling); diff --git a/ui/app/serializers/allocation.js b/ui/app/serializers/allocation.js index 33cb3ebf5..8a7a5d43b 100644 --- a/ui/app/serializers/allocation.js +++ b/ui/app/serializers/allocation.js @@ -1,10 +1,9 @@ -import Ember from 'ember'; +import { inject as service } from '@ember/service'; +import { get } from '@ember/object'; import ApplicationSerializer from './application'; -const { get, inject } = Ember; - export default ApplicationSerializer.extend({ - system: inject.service(), + system: service(), attrs: { taskGroupName: 'TaskGroup', diff --git a/ui/app/serializers/application.js b/ui/app/serializers/application.js index bdec30fc1..3d63f5b63 100644 --- a/ui/app/serializers/application.js +++ b/ui/app/serializers/application.js @@ -1,8 +1,6 @@ -import Ember from 'ember'; +import { makeArray } from '@ember/array'; import JSONSerializer from 'ember-data/serializers/json'; -const { makeArray } = Ember; - export default JSONSerializer.extend({ primaryKey: 'ID', diff --git a/ui/app/serializers/deployment.js b/ui/app/serializers/deployment.js index 04e5475a0..2a21f484e 100644 --- a/ui/app/serializers/deployment.js +++ b/ui/app/serializers/deployment.js @@ -1,8 +1,7 @@ -import Ember from 'ember'; +import { get } from '@ember/object'; +import { assign } from '@ember/polyfills'; import ApplicationSerializer from './application'; -const { get, assign } = Ember; - export default ApplicationSerializer.extend({ attrs: { versionNumber: 'JobVersion', diff --git a/ui/app/serializers/evaluation.js b/ui/app/serializers/evaluation.js index 76a2b9b3c..e5faad410 100644 --- a/ui/app/serializers/evaluation.js +++ b/ui/app/serializers/evaluation.js @@ -1,10 +1,10 @@ -import Ember from 'ember'; +import { inject as service } from '@ember/service'; +import { get } from '@ember/object'; +import { assign } from '@ember/polyfills'; import ApplicationSerializer from './application'; -const { inject, get, assign } = Ember; - export default ApplicationSerializer.extend({ - system: inject.service(), + system: service(), normalize(typeHash, hash) { hash.FailedTGAllocs = Object.keys(hash.FailedTGAllocs || {}).map(key => { diff --git a/ui/app/serializers/job-version.js b/ui/app/serializers/job-version.js index 490b6d740..f05809b3f 100644 --- a/ui/app/serializers/job-version.js +++ b/ui/app/serializers/job-version.js @@ -1,8 +1,6 @@ -import Ember from 'ember'; +import { assign } from '@ember/polyfills'; import ApplicationSerializer from './application'; -const { assign } = Ember; - export default ApplicationSerializer.extend({ attrs: { number: 'Version', diff --git a/ui/app/serializers/job.js b/ui/app/serializers/job.js index 74eeb78b0..e09c95cd8 100644 --- a/ui/app/serializers/job.js +++ b/ui/app/serializers/job.js @@ -1,9 +1,8 @@ -import Ember from 'ember'; +import { get } from '@ember/object'; +import { assign } from '@ember/polyfills'; import ApplicationSerializer from './application'; import queryString from 'npm:query-string'; -const { get, assign } = Ember; - export default ApplicationSerializer.extend({ attrs: { parameterized: 'ParameterizedJob', diff --git a/ui/app/serializers/node.js b/ui/app/serializers/node.js index 376f15d12..e0ecfc9a7 100644 --- a/ui/app/serializers/node.js +++ b/ui/app/serializers/node.js @@ -1,10 +1,8 @@ -import Ember from 'ember'; +import { inject as service } from '@ember/service'; import ApplicationSerializer from './application'; -const { inject } = Ember; - export default ApplicationSerializer.extend({ - config: inject.service(), + config: service(), attrs: { httpAddr: 'HTTPAddr', diff --git a/ui/app/serializers/task-group.js b/ui/app/serializers/task-group.js index 107ddf11e..a834250c9 100644 --- a/ui/app/serializers/task-group.js +++ b/ui/app/serializers/task-group.js @@ -1,8 +1,6 @@ -import Ember from 'ember'; +import { copy } from '@ember/object/internals'; import ApplicationSerializer from './application'; -const { copy } = Ember; - export default ApplicationSerializer.extend({ normalize(typeHash, hash) { // Provide EphemeralDisk to each task diff --git a/ui/app/serializers/token.js b/ui/app/serializers/token.js index ede185aed..7ac46e611 100644 --- a/ui/app/serializers/token.js +++ b/ui/app/serializers/token.js @@ -1,8 +1,6 @@ -import Ember from 'ember'; +import { copy } from '@ember/object/internals'; import ApplicationSerializer from './application'; -const { copy } = Ember; - export default ApplicationSerializer.extend({ primaryKey: 'AccessorID', diff --git a/ui/app/services/config.js b/ui/app/services/config.js index 2c586d97c..80e38e1ea 100644 --- a/ui/app/services/config.js +++ b/ui/app/services/config.js @@ -1,14 +1,14 @@ -import Ember from 'ember'; +import { equal } from '@ember/object/computed'; +import Service from '@ember/service'; +import { get } from '@ember/object'; import config from '../config/environment'; -const { Service, get, computed } = Ember; - export default Service.extend({ unknownProperty(path) { return get(config, path); }, - isDev: computed.equal('environment', 'development'), - isProd: computed.equal('environment', 'production'), - isTest: computed.equal('environment', 'test'), + isDev: equal('environment', 'development'), + isProd: equal('environment', 'production'), + isTest: equal('environment', 'test'), }); diff --git a/ui/app/services/ember-freestyle.js b/ui/app/services/ember-freestyle.js index fa276c8d9..a30217765 100644 --- a/ui/app/services/ember-freestyle.js +++ b/ui/app/services/ember-freestyle.js @@ -1,5 +1,5 @@ import EmberFreestyle from 'ember-freestyle/services/ember-freestyle'; export default EmberFreestyle.extend({ - defaultTheme: 'monokai-sublime', + defaultTheme: 'solarized-light', }); diff --git a/ui/app/services/system.js b/ui/app/services/system.js index 111e3bfc2..558b411a9 100644 --- a/ui/app/services/system.js +++ b/ui/app/services/system.js @@ -1,12 +1,11 @@ -import Ember from 'ember'; +import Service, { inject as service } from '@ember/service'; +import { computed } from '@ember/object'; import PromiseObject from '../utils/classes/promise-object'; import { namespace } from '../adapters/application'; -const { Service, computed, inject } = Ember; - export default Service.extend({ - token: inject.service(), - store: inject.service(), + token: service(), + store: service(), leader: computed(function() { const token = this.get('token'); diff --git a/ui/app/services/token.js b/ui/app/services/token.js index d51d7510b..4ce27d9c3 100644 --- a/ui/app/services/token.js +++ b/ui/app/services/token.js @@ -1,8 +1,8 @@ -import Ember from 'ember'; +import Service from '@ember/service'; +import { computed } from '@ember/object'; +import { assign } from '@ember/polyfills'; import fetch from 'nomad-ui/utils/fetch'; -const { Service, computed, assign } = Ember; - export default Service.extend({ secret: computed({ get() { diff --git a/ui/app/styles/app.scss b/ui/app/styles/app.scss index 444a30182..952709aee 100644 --- a/ui/app/styles/app.scss +++ b/ui/app/styles/app.scss @@ -1,5 +1,8 @@ -@import "./core"; -@import "./components"; -@import "./charts"; +@import './core'; +@import './components'; +@import './charts'; -@import "ember-power-select"; +@import 'ember-power-select'; + +// Only necessary in dev +@import './styleguide.scss'; diff --git a/ui/app/styles/charts/colors.scss b/ui/app/styles/charts/colors.scss index 4f3c6335f..e204c723f 100644 --- a/ui/app/styles/charts/colors.scss +++ b/ui/app/styles/charts/colors.scss @@ -10,7 +10,8 @@ $lost: $dark; fill: $queued; } - .starting, .pending { + .starting, + .pending { .layer-0 { fill: $starting; } @@ -58,7 +59,8 @@ $lost: $dark; background: $queued; } - &.starting, &.pending { + &.starting, + &.pending { background: repeating-linear-gradient( -45deg, $starting, diff --git a/ui/app/styles/charts/distribution-bar.scss b/ui/app/styles/charts/distribution-bar.scss index a59c041fb..fcd4a7824 100644 --- a/ui/app/styles/charts/distribution-bar.scss +++ b/ui/app/styles/charts/distribution-bar.scss @@ -21,8 +21,7 @@ opacity: 0; } - $color-sequence: $orange, $yellow, $green, $turquoise, $blue, $purple, - $red; + $color-sequence: $orange, $yellow, $green, $turquoise, $blue, $purple, $red; @for $i from 1 through length($color-sequence) { .slice-#{$i - 1} { diff --git a/ui/app/styles/charts/tooltip.scss b/ui/app/styles/charts/tooltip.scss index c8e17f908..580e0ab3b 100644 --- a/ui/app/styles/charts/tooltip.scss +++ b/ui/app/styles/charts/tooltip.scss @@ -18,7 +18,7 @@ &::before { pointer-events: none; display: inline-block; - content: ""; + content: ''; width: 0; height: 0; border-top: 7px solid $grey; @@ -34,7 +34,7 @@ &::after { pointer-events: none; display: inline-block; - content: ""; + content: ''; width: 0; height: 0; border-top: 6px solid $white; diff --git a/ui/app/styles/components.scss b/ui/app/styles/components.scss index cb2162c2f..afd92f4e0 100644 --- a/ui/app/styles/components.scss +++ b/ui/app/styles/components.scss @@ -1,6 +1,5 @@ @import "./components/badge"; @import "./components/boxed-section"; -@import "./components/breadcrumbs"; @import "./components/cli-window"; @import "./components/ember-power-select"; @import "./components/empty-message"; @@ -12,6 +11,7 @@ @import "./components/loading-spinner"; @import "./components/metrics"; @import "./components/node-status-light"; +@import "./components/page-layout"; @import "./components/simple-list"; @import "./components/status-text"; @import "./components/timeline"; diff --git a/ui/app/styles/components/breadcrumbs.scss b/ui/app/styles/components/breadcrumbs.scss deleted file mode 100644 index c3ec634a6..000000000 --- a/ui/app/styles/components/breadcrumbs.scss +++ /dev/null @@ -1,26 +0,0 @@ -.breadcrumbs { - .breadcrumb { - color: $white; - opacity: 0.7; - text-decoration: none; - - + .breadcrumb { - margin-left: 15px; - &::before { - content: "/"; - color: $primary; - position: relative; - left: -7px; - } - } - - &:last-child { - opacity: 1; - } - } - - a.breadcrumb:hover { - color: $primary-invert; - opacity: 1; - } -} diff --git a/ui/app/styles/components/json-viewer.scss b/ui/app/styles/components/json-viewer.scss index dd215bc97..4c6b1431f 100644 --- a/ui/app/styles/components/json-viewer.scss +++ b/ui/app/styles/components/json-viewer.scss @@ -13,7 +13,9 @@ $url-color: blue ) { font-family: monospace; - &, a, a:hover { + &, + a, + a:hover { color: $default-color; text-decoration: none; } @@ -31,10 +33,10 @@ display: none; } &.json-formatter-object:after { - content: "No properties"; + content: 'No properties'; } &.json-formatter-array:after { - content: "[]"; + content: '[]'; } } } @@ -100,7 +102,7 @@ // Inline preview on hover (optional) > a > .json-formatter-preview-text { opacity: 0; - transition: opacity .15s ease-in; + transition: opacity 0.15s ease-in; font-style: italic; } diff --git a/ui/app/styles/core/page-layout.scss b/ui/app/styles/components/page-layout.scss similarity index 92% rename from ui/app/styles/core/page-layout.scss rename to ui/app/styles/components/page-layout.scss index 26c8de78e..d463be113 100644 --- a/ui/app/styles/core/page-layout.scss +++ b/ui/app/styles/components/page-layout.scss @@ -1,8 +1,3 @@ -html, body, body > .ember-view { - height: 100%; - width: 100%; -} - .page-layout { height: 100%; display: flex; diff --git a/ui/app/styles/components/timeline.scss b/ui/app/styles/components/timeline.scss index bc3ab077a..553257c6c 100644 --- a/ui/app/styles/components/timeline.scss +++ b/ui/app/styles/components/timeline.scss @@ -4,7 +4,7 @@ z-index: $z-base; &::before { - content: " "; + content: ' '; position: absolute; display: block; top: 0; @@ -32,7 +32,7 @@ } &::before { - content: " "; + content: ' '; position: absolute; display: block; width: 10px; diff --git a/ui/app/styles/components/tooltip.scss b/ui/app/styles/components/tooltip.scss index bcb851033..68842c133 100644 --- a/ui/app/styles/components/tooltip.scss +++ b/ui/app/styles/components/tooltip.scss @@ -31,7 +31,7 @@ pointer-events: none; display: block; opacity: 0; - content: ""; + content: ''; width: 0; height: 0; border-top: 6px solid $black; diff --git a/ui/app/styles/core.scss b/ui/app/styles/core.scss index 3021d6be0..e4391415b 100644 --- a/ui/app/styles/core.scss +++ b/ui/app/styles/core.scss @@ -1,60 +1,28 @@ // Utils +@import "./utils/reset.scss"; @import "./utils/z-indices"; +@import "./utils/product-colors"; +@import "./utils/bumper"; // Start with Bulma variables as a foundation @import "bulma/sass/utilities/initial-variables"; // Override variables where appropriate -@import "./utils/product-colors"; - -$orange: #fa8e23; -$green: #2eb039; -$blue: $vagrant-blue; -$purple: $terraform-purple; -$red: #c84034; -$grey-blue: #bbc4d1; - -$primary: $nomad-green; -$warning: $orange; -$warning-invert: $white; -$danger: $red; -$dark: #234; - -$radius: 2px; - -$body-size: 14px; -$title-size: 1.75rem; -$size-5: 1.15rem; -$size-4: 1.3rem; -$size-7: 0.85rem; - -$title-weight: $weight-semibold; - -$family-sans-serif: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, - Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif; - -$text: $black; - -$header-height: 112px; -$gutter-width: 250px; - -$icon-dimensions: 1.25rem; -$icon-dimensions-small: 1rem; -$icon-dimensions-medium: 1.5rem; -$icon-dimensions-large: 2.5rem; +@import "./core/variables.scss"; // Bring in the rest of Bulma @import "bulma/bulma"; // Override Bulma details where appropriate @import "./core/buttons"; +@import "./core/breadcrumb"; @import "./core/columns"; @import "./core/forms"; @import "./core/icon"; @import "./core/level"; @import "./core/menu"; @import "./core/message"; -@import "./core/nav"; +@import "./core/navbar"; @import "./core/notification"; @import "./core/pagination"; @import "./core/progress"; @@ -64,7 +32,3 @@ $icon-dimensions-large: 2.5rem; @import "./core/tag"; @import "./core/title"; @import "./core/typography"; - -// Add unique core extensions -@import "./core/page-layout"; -@import "./core/bumper"; diff --git a/ui/app/styles/core/breadcrumb.scss b/ui/app/styles/core/breadcrumb.scss new file mode 100644 index 000000000..182decee3 --- /dev/null +++ b/ui/app/styles/core/breadcrumb.scss @@ -0,0 +1,15 @@ +.breadcrumb { + a { + text-decoration: none; + opacity: 0.7; + + &:hover { + text-decoration: none; + opacity: 1; + } + } + + li.is-active a { + opacity: 1; + } +} diff --git a/ui/app/styles/core/forms.scss b/ui/app/styles/core/forms.scss index 741dd892e..19ad29950 100644 --- a/ui/app/styles/core/forms.scss +++ b/ui/app/styles/core/forms.scss @@ -4,7 +4,12 @@ border-color: $grey-blue; color: $text; - &:hover, &.is-hovered, &:active, &.is-active, &:focus, &.is-focused { + &:hover, + &.is-hovered, + &:active, + &.is-active, + &:focus, + &.is-focused { border-color: darken($grey-blue, 5%); } @@ -14,7 +19,8 @@ } } -.input, .textarea { +.input, +.textarea { @include input; box-shadow: none; padding: 0.75em 1.5em; diff --git a/ui/app/styles/core/message.scss b/ui/app/styles/core/message.scss index b5e71e6b7..dba6ffa4b 100644 --- a/ui/app/styles/core/message.scss +++ b/ui/app/styles/core/message.scss @@ -1,8 +1,8 @@ .message { - background: $body-background; + background: $body-background-color; .message-header { - background: $body-background; + background: $body-background-color; color: $text; font-size: $size-5; font-weight: $weight-semibold; diff --git a/ui/app/styles/core/nav.scss b/ui/app/styles/core/navbar.scss similarity index 71% rename from ui/app/styles/core/nav.scss rename to ui/app/styles/core/navbar.scss index b1c6d6079..39232665d 100644 --- a/ui/app/styles/core/nav.scss +++ b/ui/app/styles/core/navbar.scss @@ -1,21 +1,18 @@ -.nav { +.navbar { &.is-primary { - background: linear-gradient( - to right, - $nomad-green-darker, - $nomad-green-dark - ); + background: linear-gradient(to right, $nomad-green-darker, $nomad-green-dark); height: 3.5rem; color: $primary-invert; padding-left: 20px; padding-right: 20px; - .nav-item { + .navbar-item { color: rgba($primary-invert, 0.8); text-decoration: none; &:hover { color: $primary-invert; + background: transparent; } &.is-active, @@ -24,14 +21,14 @@ border-bottom-color: $primary-invert; } - + .nav-item { + + .navbar-item { position: relative; &::before { width: 1px; height: 1em; background: rgba($primary-invert, 0.5); - content: " "; + content: ' '; display: block; position: absolute; left: 0px; @@ -44,6 +41,15 @@ max-height: 26px; } } + + .navbar-end > a.navbar-item { + color: rgba($primary-invert, 0.8); + + &:hover { + color: $primary-invert; + background: transparent; + } + } } &.is-secondary { @@ -53,12 +59,12 @@ font-weight: $weight-semibold; color: $primary-invert; - .nav-item { + .navbar-item { font-size: $size-4; } } - .nav-item { + .navbar-item { &.is-gutter { width: $gutter-width; } diff --git a/ui/app/styles/core/table.scss b/ui/app/styles/core/table.scss index 171379635..ecfacdd86 100644 --- a/ui/app/styles/core/table.scss +++ b/ui/app/styles/core/table.scss @@ -3,6 +3,7 @@ border-radius: $radius; border: 1px solid $grey-blue; border-collapse: separate; + width: 100%; &.is-fixed { table-layout: fixed; @@ -146,7 +147,7 @@ position: relative; &::after { - content: ""; + content: ''; width: 10px; right: 1.5em; top: 0.75em; @@ -156,11 +157,11 @@ } &.asc::after { - content: "⬇"; + content: '⬇'; } &.desc::after { - content: "⬆"; + content: '⬆'; } } @@ -185,7 +186,7 @@ &::after { position: absolute; - content: ""; + content: ''; width: 3px; top: 0; bottom: 0; diff --git a/ui/app/styles/core/tag.scss b/ui/app/styles/core/tag.scss index de4f25c74..cbf2bbe3b 100644 --- a/ui/app/styles/core/tag.scss +++ b/ui/app/styles/core/tag.scss @@ -1,4 +1,6 @@ -.tag { +// Strange selector mirrors selector used in Bulma +// https://github.com/jgthms/bulma/issues/912 +.tag:not(body) { text-transform: uppercase; border-radius: 200px; font-weight: $weight-normal; diff --git a/ui/app/styles/core/variables.scss b/ui/app/styles/core/variables.scss new file mode 100644 index 000000000..bef639661 --- /dev/null +++ b/ui/app/styles/core/variables.scss @@ -0,0 +1,41 @@ +$orange: #fa8e23; +$green: #2eb039; +$blue: $vagrant-blue; +$purple: $terraform-purple; +$red: #c84034; +$grey-blue: #bbc4d1; + +$primary: $nomad-green; +$warning: $orange; +$warning-invert: $white; +$danger: $red; +$info: $blue; +$dark: #234; + +$radius: 2px; + +$body-size: 14px; +$title-size: 1.75rem; +$size-5: 1.15rem; +$size-4: 1.3rem; +$size-7: 0.85rem; + +$title-weight: $weight-semibold; + +$family-sans-serif: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen-Sans, Ubuntu, + Cantarell, 'Helvetica Neue', sans-serif; + +$text: $black; + +$header-height: 112px; +$gutter-width: 250px; + +$icon-dimensions: 1.25rem; +$icon-dimensions-small: 1rem; +$icon-dimensions-medium: 1.5rem; +$icon-dimensions-large: 2.5rem; + +$breadcrumb-item-color: $white; +$breadcrumb-item-hover-color: $white; +$breadcrumb-item-active-color: $white; +$breadcrumb-item-separator-color: $primary; diff --git a/ui/app/styles/styleguide.scss b/ui/app/styles/styleguide.scss new file mode 100644 index 000000000..f77df0655 --- /dev/null +++ b/ui/app/styles/styleguide.scss @@ -0,0 +1,44 @@ +#styleguide { + .mock-content { + display: flex; + height: 250px; + + .mock-image, + .mock-copy { + height: 100%; + width: 100%; + margin: 1em; + } + + .mock-image { + background: linear-gradient( + to top right, + transparent 0%, + transparent 49%, + $grey-blue 49%, + $grey-blue 51%, + transparent 51%, + transparent 100% + ), + linear-gradient( + to bottom right, + transparent 0%, + transparent 49%, + $grey-blue 49%, + $grey-blue 51%, + transparent 51%, + transparent 100% + ); + } + + .mock-copy { + background: repeating-linear-gradient( + to bottom, + $grey-blue, + $grey-blue 5px, + transparent 5px, + transparent 14px + ); + } + } +} diff --git a/ui/app/styles/core/bumper.scss b/ui/app/styles/utils/bumper.scss similarity index 100% rename from ui/app/styles/core/bumper.scss rename to ui/app/styles/utils/bumper.scss diff --git a/ui/app/styles/utils/product-colors.scss b/ui/app/styles/utils/product-colors.scss index 689b9e84e..bbf4b63aa 100644 --- a/ui/app/styles/utils/product-colors.scss +++ b/ui/app/styles/utils/product-colors.scss @@ -1,15 +1,15 @@ -$consul-pink: #FF0087; -$consul-pink-dark: #C62A71; +$consul-pink: #ff0087; +$consul-pink-dark: #c62a71; -$packer-blue: #1DAEFF; -$packer-blue-dark: #1D94DD; +$packer-blue: #1daeff; +$packer-blue-dark: #1d94dd; -$terraform-purple: #5C4EE5; -$terraform-purple-dark: #4040B2; +$terraform-purple: #5c4ee5; +$terraform-purple-dark: #4040b2; -$vagrant-blue: #1563FF; -$vagrant-blue-dark: #104EB2; +$vagrant-blue: #1563ff; +$vagrant-blue-dark: #104eb2; -$nomad-green: #25BA81; +$nomad-green: #25ba81; $nomad-green-dark: #1d9467; -$nomad-green-darker: #16704D; +$nomad-green-darker: #16704d; diff --git a/ui/app/styles/utils/reset.scss b/ui/app/styles/utils/reset.scss new file mode 100644 index 000000000..42d609252 --- /dev/null +++ b/ui/app/styles/utils/reset.scss @@ -0,0 +1,6 @@ +html, +body, +body > .ember-view { + height: 100%; + width: 100%; +} diff --git a/ui/app/templates/allocations/allocation/index.hbs b/ui/app/templates/allocations/allocation/index.hbs index 96cb194b5..9778b2637 100644 --- a/ui/app/templates/allocations/allocation/index.hbs +++ b/ui/app/templates/allocations/allocation/index.hbs @@ -1,25 +1,25 @@ {{#global-header class="page-header"}} - Allocations - {{#link-to "allocations.allocation" model class="breadcrumb"}} - {{model.shortId}} - {{/link-to}} +
  • Allocations
  • +
  • + {{#link-to "allocations.allocation" model}}{{model.shortId}}{{/link-to}} +
  • {{/global-header}} {{#gutter-menu class="page-body"}}
    -

    +

    Allocation {{model.name}} {{model.clientStatus}} {{model.id}}

    -
    +
    Allocation Details Job - {{#link-to "jobs.job" model.job (query-params jobNamespace=model.job.namespace.id)}}{{model.job.name}}{{/link-to}} + {{#link-to "jobs.job" model.job (query-params jobNamespace=model.job.namespace.id) data-test-job-link}}{{model.job.name}}{{/link-to}} Client - {{#link-to "clients.client" model.node}}{{model.node.shortId}}{{/link-to}} + {{#link-to "clients.client" model.node data-test-client-link}}{{model.node.shortId}}{{/link-to}}
    @@ -33,7 +33,7 @@ source=sortedStates sortProperty=sortProperty sortDescending=sortDescending - class="is-striped tasks" as |t|}} + 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}} @@ -42,22 +42,22 @@ Addresses {{/t.head}} {{#t.body as |row|}} - - + + {{#link-to "allocations.allocation.task" row.model.allocation row.model}} {{row.model.task.name}} {{/link-to}} - {{row.model.state}} - + {{row.model.state}} + {{#if row.model.events.lastObject.displayMessage}} {{row.model.events.lastObject.displayMessage}} {{else}} No message {{/if}} - {{moment-format row.model.events.lastObject.time "MM/DD/YY HH:mm:ss"}} - + {{moment-format row.model.events.lastObject.time "MM/DD/YY HH:mm:ss"}} +
      {{#each row.model.resources.networks.firstObject.reservedPorts as |port|}}
    • diff --git a/ui/app/templates/allocations/allocation/task/index.hbs b/ui/app/templates/allocations/allocation/task/index.hbs index 20dcf01ac..aebe8eeed 100644 --- a/ui/app/templates/allocations/allocation/task/index.hbs +++ b/ui/app/templates/allocations/allocation/task/index.hbs @@ -1,24 +1,28 @@ {{#global-header class="page-header"}} - Allocations - {{#link-to "allocations.allocation" model.allocation class="breadcrumb"}} - {{model.allocation.shortId}} - {{/link-to}} - {{#link-to "allocations.allocation.task" model.allocation model class="breadcrumb"}} - {{model.name}} - {{/link-to}} +
    • Allocations
    • +
    • + {{#link-to "allocations.allocation" model.allocation data-test-breadcrumb="allocation"}} + {{model.allocation.shortId}} + {{/link-to}} +
    • +
    • + {{#link-to "allocations.allocation.task" model.allocation model data-test-breadcrumb="task"}} + {{model.name}} + {{/link-to}} +
    • {{/global-header}} {{#gutter-menu class="page-body"}} {{partial "allocations/allocation/task/subnav"}}
      -

      +

      {{model.name}} - {{model.state}} + {{model.state}}

      Task Details - + Started At {{moment-format model.startedAt "MM/DD/YY HH:mm:ss"}} @@ -36,22 +40,22 @@
      {{#if ports.length}} -
      +
      Addresses
      - {{#list-table source=ports class="addresses-list" as |t|}} + {{#list-table source=ports as |t|}} {{#t.head}} Dynamic? Name Address {{/t.head}} {{#t.body as |row|}} - - {{if row.model.isDynamic "Yes" "No"}} - {{row.model.name}} - + + {{if row.model.isDynamic "Yes" "No"}} + {{row.model.name}} + {{model.allocation.node.address}}:{{row.model.port}} @@ -68,17 +72,17 @@ Recent Events
      - {{#list-table source=(reverse model.events) class="is-striped task-events" as |t|}} + {{#list-table source=(reverse model.events) class="is-striped" as |t|}} {{#t.head}} Time Type Description {{/t.head}} {{#t.body as |row|}} - - {{moment-format row.model.time "MM/DD/YY HH:mm:ss"}} - {{row.model.type}} - + + {{moment-format row.model.time "MM/DD/YY HH:mm:ss"}} + {{row.model.type}} + {{#if row.model.displayMessage}} {{row.model.displayMessage}} {{else}} diff --git a/ui/app/templates/allocations/allocation/task/logs.hbs b/ui/app/templates/allocations/allocation/task/logs.hbs index 887d2a7d1..01a7440cf 100644 --- a/ui/app/templates/allocations/allocation/task/logs.hbs +++ b/ui/app/templates/allocations/allocation/task/logs.hbs @@ -1,15 +1,19 @@ {{#global-header class="page-header"}} - Allocations - {{#link-to "allocations.allocation" model.allocation class="breadcrumb"}} - {{model.allocation.shortId}} - {{/link-to}} - {{#link-to "allocations.allocation.task" model.allocation model class="breadcrumb"}} - {{model.name}} - {{/link-to}} +
    • Allocations
    • +
    • + {{#link-to "allocations.allocation" model.allocation}} + {{model.allocation.shortId}} + {{/link-to}} +
    • +
    • + {{#link-to "allocations.allocation.task" model.allocation model}} + {{model.name}} + {{/link-to}} +
    • {{/global-header}} {{#gutter-menu class="page-body"}} {{partial "allocations/allocation/task/subnav"}}
      - {{task-log allocation=model.allocation task=model.name}} + {{task-log data-test-task-log allocation=model.allocation task=model.name}}
      {{/gutter-menu}} diff --git a/ui/app/templates/application.hbs b/ui/app/templates/application.hbs index 2ee39614a..8d1a11672 100644 --- a/ui/app/templates/application.hbs +++ b/ui/app/templates/application.hbs @@ -3,23 +3,23 @@ {{outlet}} {{else}}
      -
      +
      {{#if is500}} -

      Server Error

      -

      A server error prevented data from being sent to the client.

      +

      Server Error

      +

      A server error prevented data from being sent to the client.

      {{else if is404}} -

      Not Found

      -

      What you're looking for couldn't be found. It either doesn't exist or you are not authorized to see it.

      +

      Not Found

      +

      What you're looking for couldn't be found. It either doesn't exist or you are not authorized to see it.

      {{else if is403}} -

      Not Authorized

      +

      Not Authorized

      {{#if token.secret}} -

      Your {{#link-to "settings.tokens"}}ACL token{{/link-to}} does not provide the required permissions. Contact your administrator if this is an error.

      +

      Your {{#link-to "settings.tokens" data-test-error-acl-link}}ACL token{{/link-to}} does not provide the required permissions. Contact your administrator if this is an error.

      {{else}} -

      Provide an {{#link-to "settings.tokens"}}ACL token{{/link-to}} with requisite permissions to view this.

      +

      Provide an {{#link-to "settings.tokens" data-test-error-acl-link}}ACL token{{/link-to}} with requisite permissions to view this.

      {{/if}} {{else}} -

      Error

      -

      Something went wrong.

      +

      Error

      +

      Something went wrong.

      {{/if}} {{#if (eq config.environment "development")}}
      {{errorStr}}
      diff --git a/ui/app/templates/clients/client.hbs b/ui/app/templates/clients/client.hbs index a4f10bd3a..09423d9d4 100644 --- a/ui/app/templates/clients/client.hbs +++ b/ui/app/templates/clients/client.hbs @@ -1,11 +1,15 @@ {{#global-header class="page-header"}} - {{#link-to "clients" class="breadcrumb"}}Clients{{/link-to}} - {{model.shortId}} +
    • + {{#link-to "clients.index" data-test-breadcrumb="clients"}}Clients{{/link-to}} +
    • +
    • + {{#link-to "clients.client" model.id data-test-breadcrumb="client"}}{{model.shortId}}{{/link-to}} +
    • {{/global-header}} {{#gutter-menu class="page-body"}}
      -

      - +

      + {{or model.name model.shortId}} {{model.id}}

      @@ -13,9 +17,9 @@
      Client Details - Status {{model.status}} - Address {{model.httpAddr}} - Datacenter {{model.datacenter}} + Status {{model.status}} + Address {{model.httpAddr}} + Datacenter {{model.datacenter}}
      @@ -37,7 +41,7 @@ source=p.list sortProperty=sortProperty sortDescending=sortDescending - class="allocations with-foot" as |t|}} + class="with-foot" as |t|}} {{#t.head}} {{#t.sort-by prop="shortId"}}ID{{/t.sort-by}} {{#t.sort-by prop="modifyIndex" title="Modify Index"}}Modified{{/t.sort-by}} @@ -49,7 +53,11 @@ Memory {{/t.head}} {{#t.body as |row|}} - {{allocation-row allocation=row.model context="node" onClick=(action "gotoAllocation" row.model)}} + {{allocation-row + allocation=row.model + context="node" + onClick=(action "gotoAllocation" row.model) + data-test-allocation=row.model.id}} {{/t.body}} {{/list-table}}
      @@ -71,7 +79,10 @@ Attributes
      - {{attributes-table attributes=model.attributes.attributesStructured class="attributes-table"}} + {{attributes-table + data-test-attributes + attributes=model.attributes.attributesStructured + class="attributes-table"}}
      diff --git a/ui/app/templates/clients/index.hbs b/ui/app/templates/clients/index.hbs index b6f0c7275..1b43c78c3 100644 --- a/ui/app/templates/clients/index.hbs +++ b/ui/app/templates/clients/index.hbs @@ -1,5 +1,7 @@ {{#global-header class="page-header"}} - Clients +
    • + {{#link-to "clients.index"}}Clients{{/link-to}} +
    • {{/global-header}} {{#gutter-menu class="page-body"}}
      @@ -30,11 +32,11 @@ # Allocs {{/t.head}} {{#t.body as |row|}} - {{client-node-row node=row.model onClick=(action "gotoNode" row.model)}} + {{client-node-row data-test-client-node-row node=row.model onClick=(action "gotoNode" row.model)}} {{/t.body}} {{/list-table}}
      -
      {{else}} -
      +
      {{#if (eq nodes.length 0)}} -

      No Clients

      +

      No Clients

      The cluster currently has no client nodes.

      {{else if searchTerm}} -

      No Matches

      +

      No Matches

      No clients match the term {{searchTerm}}

      {{/if}}
      diff --git a/ui/app/templates/clients/loading.hbs b/ui/app/templates/clients/loading.hbs index 7014a5a9d..4bfa29cb2 100644 --- a/ui/app/templates/clients/loading.hbs +++ b/ui/app/templates/clients/loading.hbs @@ -1,5 +1,7 @@ {{#global-header class="page-header"}} - Clients +
    • + {{#link-to "clients.index"}}Clients{{/link-to}} +
    • {{/global-header}} {{#gutter-menu class="page-body"}}
      {{partial "partials/loading-spinner"}}
      diff --git a/ui/app/templates/components/allocation-row.hbs b/ui/app/templates/components/allocation-row.hbs index 0820dfeb5..7656960e5 100644 --- a/ui/app/templates/components/allocation-row.hbs +++ b/ui/app/templates/components/allocation-row.hbs @@ -1,28 +1,28 @@ - + {{#link-to "allocations.allocation" allocation class="is-primary"}} {{allocation.shortId}} {{/link-to}} -{{moment-format allocation.modifyTime "MM/DD HH:mm:ss"}} -{{allocation.name}} - +{{moment-format allocation.modifyTime "MM/DD HH:mm:ss"}} +{{allocation.name}} + {{allocation.clientStatus}} {{#if (eq context "job")}} - {{allocation.jobVersion}} - {{#link-to "clients.client" allocation.node}}{{allocation.node.shortId}}{{/link-to}} + {{allocation.jobVersion}} + {{#link-to "clients.client" allocation.node}}{{allocation.node.shortId}}{{/link-to}} {{else if (eq context "node")}} {{#if (or allocation.job.isPending allocation.job.isReloading)}} ... {{else}} - {{#link-to "jobs.job" allocation.job (query-params jobNamespace=allocation.job.namespace.id)}}{{allocation.job.name}}{{/link-to}} - / {{allocation.taskGroup.name}} + {{#link-to "jobs.job" allocation.job (query-params jobNamespace=allocation.job.namespace.id) data-test-job}}{{allocation.job.name}}{{/link-to}} + / {{allocation.taskGroup.name}} {{/if}} - {{allocation.jobVersion}} + {{allocation.jobVersion}} {{/if}} - + {{#if allocation.stats.isPending}} ... {{else if allocation.stats.isRejected}} @@ -40,7 +40,7 @@
      {{/if}} - + {{#if allocation.stats.isPending}} ... {{else if allocation.stats.isRejected}} diff --git a/ui/app/templates/components/attributes-section.hbs b/ui/app/templates/components/attributes-section.hbs index dd55bb591..8abeeb135 100644 --- a/ui/app/templates/components/attributes-section.hbs +++ b/ui/app/templates/components/attributes-section.hbs @@ -1,18 +1,18 @@ {{#each-in attributes as |key value|}} {{#if (is-object value)}} - - - {{#if prefix}}{{prefix}}.{{/if}}{{key}} + + + {{#if prefix}}{{prefix}}.{{/if}}{{key}} {{attributes-section prefix=(if prefix (concat prefix '.' key) key) attributes=value}} {{else}} - - - {{#if prefix}}{{prefix}}.{{/if}} + + + {{#if prefix}}{{prefix}}.{{/if}} {{~key}} - {{value}} + {{value}} {{/if}} {{/each-in}} diff --git a/ui/app/templates/components/client-node-row.hbs b/ui/app/templates/components/client-node-row.hbs index b6eb25413..184a00e2b 100644 --- a/ui/app/templates/components/client-node-row.hbs +++ b/ui/app/templates/components/client-node-row.hbs @@ -1,10 +1,10 @@ -{{#link-to "clients.client" node.id class="is-primary"}}{{node.shortId}}{{/link-to}} -{{node.name}} -{{node.status}} -{{node.address}} -{{node.port}} -{{node.datacenter}} - +{{#link-to "clients.client" node.id class="is-primary"}}{{node.shortId}}{{/link-to}} +{{node.name}} +{{node.status}} +{{node.address}} +{{node.port}} +{{node.datacenter}} + {{#if node.allocations.isPending}} ... {{else}} diff --git a/ui/app/templates/components/freestyle/sg-boxed-section.hbs b/ui/app/templates/components/freestyle/sg-boxed-section.hbs new file mode 100644 index 000000000..52b39c823 --- /dev/null +++ b/ui/app/templates/components/freestyle/sg-boxed-section.hbs @@ -0,0 +1,183 @@ +{{#freestyle-collection defaultKey="Normal" as |collection|}} + {{#collection.variant key="Normal"}} + {{#freestyle-usage "boxed-section-normal-normal" title="Normal Boxed Section"}} +
      +
      + Normal Boxed Section +
      +
      +
      +
      +
      +
      +
      +
      +
      + {{/freestyle-usage}} + {{/collection.variant}} + {{#collection.variant key="Info"}} + {{#freestyle-usage "boxed-section-normal-info" title="Info Boxed Section"}} +
      +
      + Normal Boxed Section +
      +
      +
      +
      +
      +
      +
      +
      +
      + {{/freestyle-usage}} + {{/collection.variant}} + {{#collection.variant key="Warning"}} + {{#freestyle-usage "boxed-section-normal-warning" title="Warning Boxed Section"}} +
      +
      + Normal Boxed Section +
      +
      +
      +
      +
      +
      +
      +
      +
      + {{/freestyle-usage}} + {{/collection.variant}} + {{#collection.variant key="Danger"}} + {{#freestyle-usage "boxed-section-normal-danger" title="Danger Boxed Section"}} +
      +
      + Normal Boxed Section +
      +
      +
      +
      +
      +
      +
      +
      +
      + {{/freestyle-usage}} + {{/collection.variant}} +{{/freestyle-collection}} + +{{#freestyle-collection defaultKey="Normal" as |collection|}} + {{#each variants as |variant|}} + {{#collection.variant key=variant.key}} + {{#freestyle-usage "boxed-section-right-hand-normal" title=(concat variant.title "Normal Boxed Section With Right Hand Details")}} +
      +
      + Boxed Section With Right Hand Details + {{now interval=1000}} +
      +
      +
      +
      +
      +
      +
      +
      +
      + {{/freestyle-usage}} + {{/collection.variant}} + {{/each}} +{{/freestyle-collection}} + +{{#freestyle-collection defaultKey="Normal" as |collection|}} + {{#each variants as |variant|}} + {{#collection.variant key=variant.key}} + {{#freestyle-usage "boxed-section-left-badge-normal" title=(concat variant.title " Normal Boxed Section With Title Decoration")}} +
      +
      + Boxed Section With Title Decoration + 7 +
      +
      +
      +
      +
      +
      +
      +
      +
      + {{/freestyle-usage}} + {{/collection.variant}} + {{/each}} +{{/freestyle-collection}} + +{{#freestyle-collection defaultKey="Normal" as |collection|}} + {{#each variants as |variant|}} + {{#collection.variant key=variant.key}} + {{#freestyle-usage "boxed-section-with-foot-normal" title=(concat variant.title " Boxed Section With Foot")}} +
      +
      + Boxed Section With Large Header +
      +
      +
      +
      +
      +
      +
      +
      +
      + Left-aligned message + Toggle or other action +
      +
      + {{/freestyle-usage}} + {{/collection.variant}} + {{/each}} +{{/freestyle-collection}} + +{{#freestyle-collection defaultKey="Normal" as |collection|}} + {{#each variants as |variant|}} + {{#collection.variant key=variant.key}} + {{#freestyle-usage "boxed-section-with-large-header" title=(concat variant.title " Boxed Section With Large Header")}} +
      +
      +
      + Boxed Section With Large Header + Status +
      +
      + A tag that goes on a second line because it's rather long +
      +
      +
      +
      +
      +
      +
      +
      +
      +
      + {{/freestyle-usage}} + {{/collection.variant}} + {{/each}} +{{/freestyle-collection}} + +{{#freestyle-collection defaultKey="Normal" as |collection|}} + {{#each variants as |variant|}} + {{#collection.variant key=variant.key}} + {{#freestyle-usage "boxed-section-with-dark-body" title=(concat variant.title " Boxed Section With Dark Body")}} +
      +
      + Boxed Section With Dark Body +
      +
      +
      +
      +
      +
      +
      +
      +
      + {{/freestyle-usage}} + {{/collection.variant}} + {{/each}} +{{/freestyle-collection}} diff --git a/ui/app/templates/components/freestyle/sg-breadcrumbs.hbs b/ui/app/templates/components/freestyle/sg-breadcrumbs.hbs new file mode 100644 index 000000000..8ad173adf --- /dev/null +++ b/ui/app/templates/components/freestyle/sg-breadcrumbs.hbs @@ -0,0 +1,33 @@ +{{#freestyle-usage 'breadcrumbs-standard' title='Standard Breadcrumbs'}} + +{{/freestyle-usage}} +{{#freestyle-annotation}} + Breadcrumbs are only ever used in the secondary nav of the primary header. +{{/freestyle-annotation}} + +{{#freestyle-usage 'breadcrumbs-single' title='Single Breadcrumb'}} + +{{/freestyle-usage}} +{{#freestyle-annotation}} + Breadcrumbs are given a lot of emphasis and often double as a page title. Since they are also global state, they are important for helping a user keep their bearings. +{{/freestyle-annotation}} diff --git a/ui/app/templates/components/freestyle/sg-buttons.hbs b/ui/app/templates/components/freestyle/sg-buttons.hbs new file mode 100644 index 000000000..069427c59 --- /dev/null +++ b/ui/app/templates/components/freestyle/sg-buttons.hbs @@ -0,0 +1,54 @@ +{{#freestyle-collection defaultKey="standard" as |collection|}} + {{#collection.variant key="standard"}} + {{#freestyle-usage 'buttons-standard' title='Standard Buttons'}} +
      + Button + White + Light + Dark + Black + Link +
      + + {{/freestyle-usage}} + {{/collection.variant}} + {{#collection.variant key="outlines"}} + {{#freestyle-usage 'buttons-outlines' title='Outline Buttons'}} + + {{/freestyle-usage}} + {{/collection.variant}} + {{#collection.variant key="hollow"}} + {{#freestyle-usage 'buttons-hollow' title='Hollow Buttons'}} + + {{/freestyle-usage}} + {{/collection.variant}} + {{#collection.variant key="sizing"}} + {{#freestyle-usage 'buttons-sizing' title='Button Sizes'}} +
      + Small + Normal + Medium + Large +
      + {{/freestyle-usage}} + {{/collection.variant}} +{{/freestyle-collection}} diff --git a/ui/app/templates/components/freestyle/sg-colors.hbs b/ui/app/templates/components/freestyle/sg-colors.hbs new file mode 100644 index 000000000..b0f69844f --- /dev/null +++ b/ui/app/templates/components/freestyle/sg-colors.hbs @@ -0,0 +1,16 @@ +{{#freestyle-usage "colors"}} + {{freestyle-palette + colorPalette=nomadTheme + title="Nomad Theme" + description="Accent and neutrals."}} + + {{freestyle-palette + colorPalette=productColors + title="Product Colors" + description="Colors from other HashiCorp products. Often borrowed for alternative accents and color schemes."}} + + {{freestyle-palette + colorPalette=emotiveColors + title="Emotive Colors" + description="Colors used in conjunction with an emotional response."}} +{{/freestyle-usage}} diff --git a/ui/app/templates/components/freestyle/sg-distribution-bar-jumbo.hbs b/ui/app/templates/components/freestyle/sg-distribution-bar-jumbo.hbs new file mode 100644 index 000000000..924f3ed7d --- /dev/null +++ b/ui/app/templates/components/freestyle/sg-distribution-bar-jumbo.hbs @@ -0,0 +1,25 @@ +{{#freestyle-usage "jumbo-distribution-bar"}} + {{#distribution-bar data=distributionBarData class="split-view" as |chart|}} +
        + {{#each chart.data as |datum index|}} +
      1. + + {{datum.value}} + + {{datum.label}} + +
      2. + {{/each}} +
      + {{/distribution-bar}} +{{/freestyle-usage}} +{{#freestyle-annotation}} +
      + A variation of the Distribution Bar component for when the distribution bar is the central component of the page. It's a larger format that requires no interaction to see the data labels and values. +
      +
      +
      + {{json-viewer json=distributionBarData}} +
      +
      +{{/freestyle-annotation}} diff --git a/ui/app/templates/components/freestyle/sg-distribution-bar.hbs b/ui/app/templates/components/freestyle/sg-distribution-bar.hbs new file mode 100644 index 000000000..3fbc6d993 --- /dev/null +++ b/ui/app/templates/components/freestyle/sg-distribution-bar.hbs @@ -0,0 +1,70 @@ +{{#freestyle-collection as |collection|}} + {{#collection.variant key="standard"}} + {{#freestyle-usage 'distribution-bar-standard'}} +
      + {{distribution-bar data=distributionBarData}} +
      + {{/freestyle-usage}} + {{#freestyle-annotation}} +
      + The distribution bar chart proportionally show data in a single bar. It includes a tooltip out of the box, assumes the size of the container element, and is designed to be styled with CSS. +
      +
      +
      + {{json-viewer json=distributionBarData}} +
      +
      + {{/freestyle-annotation}} + {{/collection.variant}} + {{#collection.variant key="with classes"}} + {{#freestyle-usage 'distribution-bar-with-classes'}} +
      + {{distribution-bar data=distributionBarDataWithClasses}} +
      + {{/freestyle-usage}} + {{#freestyle-annotation}} +
      + If a datum provides a className property, it will be assigned to the corresponding rect element, allowing for custom colorization. +
      +
      +
      + {{json-viewer json=distributionBarDataWithClasses}} +
      +
      + {{/freestyle-annotation}} + {{/collection.variant}} + {{#collection.variant key="flexibility"}} + {{#freestyle-usage 'distribution-bar-sizing-1'}} +
      + {{distribution-bar data=distributionBarData}} +
      + {{/freestyle-usage}} + {{#freestyle-usage 'distribution-bar-sizing-2'}} +
      + {{distribution-bar data=distributionBarData}} +
      + {{/freestyle-usage}} + {{#freestyle-annotation}} +
      + Distribution bar assumes the dimensions of the container. +
      + {{/freestyle-annotation}} + {{/collection.variant}} + {{#collection.variant key="live updating"}} + {{#freestyle-usage 'distribution-bar-updating'}} +
      + {{distribution-bar data=distributionBarDataRotating}} +
      + {{/freestyle-usage}} + {{#freestyle-annotation}} +
      + Distribution bar animates with data changes. +
      +
      +
      + {{json-viewer json=distributionBarDataRotating}} +
      +
      + {{/freestyle-annotation}} + {{/collection.variant}} +{{/freestyle-collection}} diff --git a/ui/app/templates/components/freestyle/sg-font-sizing.hbs b/ui/app/templates/components/freestyle/sg-font-sizing.hbs new file mode 100644 index 000000000..02b3b38c0 --- /dev/null +++ b/ui/app/templates/components/freestyle/sg-font-sizing.hbs @@ -0,0 +1,14 @@ +{{#freestyle-usage "font-sizing"}} +
      +

      Large Title

      +

      Some prose to follow the large title. Not necessarily meant for reading.

      +
      +
      +

      Medium Title

      +

      Some prose to follow the large title. Not necessarily meant for reading.

      +
      +
      +

      Small Title

      +

      Some prose to follow the large title. Not necessarily meant for reading.

      +
      +{{/freestyle-usage}} diff --git a/ui/app/templates/components/freestyle/sg-font-stacks.hbs b/ui/app/templates/components/freestyle/sg-font-stacks.hbs new file mode 100644 index 000000000..9b8dfb2e6 --- /dev/null +++ b/ui/app/templates/components/freestyle/sg-font-stacks.hbs @@ -0,0 +1,52 @@ +{{#freestyle-collection inline=true as |collection|}} + {{#collection.variant key="-apple-system"}} + {{#freestyle-usage "font-apple-system" title="-apple-system"}} + {{freestyle-typeface fontFamily="-apple-system"}} + {{/freestyle-usage}} + {{/collection.variant}} + {{#collection.variant key="BlinkMacSystemFont"}} + {{#freestyle-usage "font-blink-mac-system" title="BlinkMacSystemFont"}} + {{freestyle-typeface fontFamily="BlinkMacSystemFont"}} + {{/freestyle-usage}} + {{/collection.variant}} + {{#collection.variant key="Segoe UI"}} + {{#freestyle-usage "font-segoe-ui" title="Segoe UI"}} + {{freestyle-typeface fontFamily="Segoe UI"}} + {{/freestyle-usage}} + {{/collection.variant}} + {{#collection.variant key="Roboto"}} + {{#freestyle-usage "font-roboto" title="Roboto"}} + {{freestyle-typeface fontFamily="Roboto"}} + {{/freestyle-usage}} + {{/collection.variant}} + {{#collection.variant key="Oxygen Sans"}} + {{#freestyle-usage "font-oxygen-sans" title="Oxygen Sans"}} + {{freestyle-typeface fontFamily="Oxygen-Sans"}} + {{/freestyle-usage}} + {{/collection.variant}} + {{#collection.variant key="Ubuntu"}} + {{#freestyle-usage "font-ubuntu" title="Ubuntu"}} + {{freestyle-typeface fontFamily="Ubuntu"}} + {{/freestyle-usage}} + {{/collection.variant}} + {{#collection.variant key="Cantarell"}} + {{#freestyle-usage "font-cantarell" title="Cantarell"}} + {{freestyle-typeface fontFamily="Cantarell"}} + {{/freestyle-usage}} + {{/collection.variant}} + {{#collection.variant key="Helvetica Neue"}} + {{#freestyle-usage "font-helvetica-neue" title="Helvetica Neue"}} + {{freestyle-typeface fontFamily="Helvetica Neue"}} + {{/freestyle-usage}} + {{/collection.variant}} + {{#collection.variant key="sans-serif"}} + {{#freestyle-usage "font-sans-serif" title="sans-serif"}} + {{freestyle-typeface fontFamily="sans-serif"}} + {{/freestyle-usage}} + {{/collection.variant}} + {{#collection.variant key="monospace"}} + {{#freestyle-usage "font-monospace" title="monospace"}} + {{freestyle-typeface fontFamily="monospace"}} + {{/freestyle-usage}} + {{/collection.variant}} +{{/freestyle-collection}} diff --git a/ui/app/templates/components/global-header.hbs b/ui/app/templates/components/global-header.hbs index 41eeba082..8996b11d4 100644 --- a/ui/app/templates/components/global-header.hbs +++ b/ui/app/templates/components/global-header.hbs @@ -1,22 +1,22 @@ -
    + +
    diff --git a/ui/app/templates/components/gutter-menu.hbs b/ui/app/templates/components/gutter-menu.hbs index 765b53ab9..055e8e323 100644 --- a/ui/app/templates/components/gutter-menu.hbs +++ b/ui/app/templates/components/gutter-menu.hbs @@ -9,6 +9,7 @@
  • +
  • {{#if record.deployment.version.submitTime}} {{moment-format record.deployment.version.submitTime "MMMM D, YYYY"}} {{else}} @@ -8,7 +8,7 @@ {{/if}}
  • {{/if}} -
  • +
  • {{job-deployment deployment=record.deployment}}
  • {{/each}} diff --git a/ui/app/templates/components/job-diff-fields-and-objects.hbs b/ui/app/templates/components/job-diff-fields-and-objects.hbs index 7da3703c8..dfb51e6e4 100644 --- a/ui/app/templates/components/job-diff-fields-and-objects.hbs +++ b/ui/app/templates/components/job-diff-fields-and-objects.hbs @@ -1,9 +1,12 @@
    {{#each fields as |field|}} -
    +
    {{#if (eq (lowercase field.Type) "added")}} @@ -30,10 +33,13 @@
    {{#each objects as |object|}} -
    +
    {{#if (eq (lowercase object.Type) "added")}} + @@ -44,7 +50,7 @@ {{/if}} {{object.Name}} { -