diff --git a/CHANGELOG.md b/CHANGELOG.md index f5a2ca22a..3077c6977 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,24 +6,31 @@ __BACKWARDS INCOMPATIBILITIES:__ in HTTP check paths will now fail to validate. [[GH-3685](https://github.com/hashicorp/nomad/issues/3685)] IMPROVEMENTS: + * core: Allow upgrading/downgrading TLS via SIGHUP on both servers and clients [[GH-3492](https://github.com/hashicorp/nomad/issues/3492)] * 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)] + * cli: Use ISO_8601 time format for cli output + [[GH-3814](https://github.com/hashicorp/nomad/pull/3814)] * client: Allow '.' in environment variable names [[GH-3760](https://github.com/hashicorp/nomad/issues/3760)] + * client: Refactor client fingerprint methods to a request/response format + [[GH-3781](https://github.com/hashicorp/nomad/issues/3781)] * discovery: Allow `check_restart` to be specified in the `service` stanza. [[GH-3718](https://github.com/hashicorp/nomad/issues/3718)] + * driver/docker: Support advertising IPv6 addresses [[GH-3790](https://github.com/hashicorp/nomad/issues/3790)] * driver/docker; Support overriding image entrypoint [[GH-3788](https://github.com/hashicorp/nomad/issues/3788)] * driver/docker: Support adding or dropping capabilities [[GH-3754](https://github.com/hashicorp/nomad/issues/3754)] * driver/docker: Support mounting root filesystem as read-only [[GH-3802](https://github.com/hashicorp/nomad/issues/3802)] * driver/lxc: Add volumes config to LXC driver [[GH-3687](https://github.com/hashicorp/nomad/issues/3687)] + * telemetry: Support DataDog tags [[GH-3839](https://github.com/hashicorp/nomad/issues/3839)] BUG FIXES: * core: Fix search endpoint forwarding for multi-region clusters [[GH-3680](https://github.com/hashicorp/nomad/issues/3680)] - * core: Allow upgrading/downgrading TLS via SIGHUP on both servers and clients [[GH-3492](https://github.com/hashicorp/nomad/issues/3492)] * core: Fix an issue in which batch jobs with queued placements and lost allocations could result in improper placement counts [[GH-3717](https://github.com/hashicorp/nomad/issues/3717)] * client: Migrated ephemeral_disk's maintain directory permissions [[GH-3723](https://github.com/hashicorp/nomad/issues/3723)] * client: Always advertise driver IP when in driver address mode [[GH-3682](https://github.com/hashicorp/nomad/issues/3682)] * client/vault: Recognize renewing non-renewable Vault lease as fatal [[GH-3727](https://github.com/hashicorp/nomad/issues/3727)] * config: Revert minimum CPU limit back to 20 from 100. + * driver/lxc: Cleanup LXC containers after errors on container startup. [[GH-3773](https://github.com/hashicorp/nomad/issues/3773)] * ui: Fix ui on non-leaders when ACLs are enabled [[GH-3722](https://github.com/hashicorp/nomad/issues/3722)] * ui: Fix requests using client-side certificates in Firefox. [[GH-3728](https://github.com/hashicorp/nomad/pull/3728)] @@ -163,8 +170,7 @@ BUG FIXES: change [[GH-3214](https://github.com/hashicorp/nomad/issues/3214)] * api: Fix search handling of jobs with more than four hyphens and case were length could cause lookup error [[GH-3203](https://github.com/hashicorp/nomad/issues/3203)] - * client: Improve the speed at which clients detect garbage collection events - [GH_-3452] + * client: Improve the speed at which clients detect garbage collection events [[GH-3452](https://github.com/hashicorp/nomad/issues/3452)] * client: Fix lock contention that could cause a node to miss a heartbeat and be marked as down [[GH-3195](https://github.com/hashicorp/nomad/issues/3195)] * client: Fix data race that could lead to concurrent map read/writes during diff --git a/GNUmakefile b/GNUmakefile index 951e7a81b..9ac6ba5be 100644 --- a/GNUmakefile +++ b/GNUmakefile @@ -148,7 +148,7 @@ deps: ## Install build and development dependencies @echo "==> Updating build dependencies..." go get -u github.com/kardianos/govendor go get -u github.com/ugorji/go/codec/codecgen - go get -u github.com/jteeuwen/go-bindata/... + go get -u github.com/hashicorp/go-bindata/... go get -u github.com/elazarl/go-bindata-assetfs/... go get -u github.com/a8m/tree/cmd/tree go get -u github.com/magiconair/vendorfmt/cmd/vendorfmt diff --git a/api/nodes.go b/api/nodes.go index e1ef5e2aa..194affde4 100644 --- a/api/nodes.go +++ b/api/nodes.go @@ -151,6 +151,7 @@ type HostDiskStats struct { // NodeListStub is a subset of information returned during // node list operations. type NodeListStub struct { + Address string ID string Datacenter string Name string diff --git a/api/operator_autopilot.go b/api/operator_autopilot.go index a61ad21d6..2dbde9dd2 100644 --- a/api/operator_autopilot.go +++ b/api/operator_autopilot.go @@ -2,6 +2,7 @@ package api import ( "bytes" + "encoding/json" "fmt" "io" "strconv" @@ -19,7 +20,7 @@ type AutopilotConfiguration struct { // LastContactThreshold is the limit on the amount of time a server can go // without leader contact before being considered unhealthy. - LastContactThreshold *ReadableDuration + LastContactThreshold time.Duration // MaxTrailingLogs is the amount of entries in the Raft Log that a server can // be behind before being considered unhealthy. @@ -28,20 +29,19 @@ type AutopilotConfiguration struct { // 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 + ServerStabilizationTime time.Duration - // (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) EnableRedundancyZones specifies whether to enable redundancy zones. + EnableRedundancyZones bool // (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 + // (Enterprise-only) EnableCustomUpgrades specifies whether to enable using custom + // upgrade versions when performing migrations. + EnableCustomUpgrades bool // CreateIndex holds the index corresponding the creation of this configuration. // This is a read-only field. @@ -54,6 +54,45 @@ type AutopilotConfiguration struct { ModifyIndex uint64 } +func (u *AutopilotConfiguration) MarshalJSON() ([]byte, error) { + type Alias AutopilotConfiguration + return json.Marshal(&struct { + LastContactThreshold string + ServerStabilizationTime string + *Alias + }{ + LastContactThreshold: u.LastContactThreshold.String(), + ServerStabilizationTime: u.ServerStabilizationTime.String(), + Alias: (*Alias)(u), + }) +} + +func (u *AutopilotConfiguration) UnmarshalJSON(data []byte) error { + type Alias AutopilotConfiguration + aux := &struct { + LastContactThreshold string + ServerStabilizationTime string + *Alias + }{ + Alias: (*Alias)(u), + } + if err := json.Unmarshal(data, &aux); err != nil { + return err + } + var err error + if aux.LastContactThreshold != "" { + if u.LastContactThreshold, err = time.ParseDuration(aux.LastContactThreshold); err != nil { + return err + } + } + if aux.ServerStabilizationTime != "" { + if u.ServerStabilizationTime, err = time.ParseDuration(aux.ServerStabilizationTime); err != nil { + return err + } + } + return nil +} + // 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. @@ -75,7 +114,7 @@ type ServerHealth struct { Leader bool // LastContact is the time since this node's last contact with the leader. - LastContact *ReadableDuration + LastContact time.Duration // LastTerm is the highest leader term this server has a record of in its Raft log. LastTerm uint64 @@ -94,6 +133,37 @@ type ServerHealth struct { StableSince time.Time } +func (u *ServerHealth) MarshalJSON() ([]byte, error) { + type Alias ServerHealth + return json.Marshal(&struct { + LastContact string + *Alias + }{ + LastContact: u.LastContact.String(), + Alias: (*Alias)(u), + }) +} + +func (u *ServerHealth) UnmarshalJSON(data []byte) error { + type Alias ServerHealth + aux := &struct { + LastContact string + *Alias + }{ + Alias: (*Alias)(u), + } + if err := json.Unmarshal(data, &aux); err != nil { + return err + } + var err error + if aux.LastContact != "" { + if u.LastContact, err = time.ParseDuration(aux.LastContact); err != nil { + return err + } + } + return nil +} + // 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. @@ -107,46 +177,6 @@ type OperatorHealthReply struct { 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") diff --git a/api/operator_autopilot_test.go b/api/operator_autopilot_test.go index 1c18e8e0f..c491d855c 100644 --- a/api/operator_autopilot_test.go +++ b/api/operator_autopilot_test.go @@ -17,13 +17,17 @@ func TestAPI_OperatorAutopilotGetSetConfiguration(t *testing.T) { defer s.Stop() operator := c.Operator() - config, err := operator.AutopilotGetConfiguration(nil) - assert.Nil(err) + var config *AutopilotConfiguration + retry.Run(t, func(r *retry.R) { + var err error + config, err = operator.AutopilotGetConfiguration(nil) + r.Check(err) + }) assert.True(config.CleanupDeadServers) // Change a config setting newConf := &AutopilotConfiguration{CleanupDeadServers: false} - err = operator.AutopilotSetConfiguration(newConf, nil) + err := operator.AutopilotSetConfiguration(newConf, nil) assert.Nil(err) config, err = operator.AutopilotGetConfiguration(nil) diff --git a/client/client.go b/client/client.go index 314b9dda4..4054fca6b 100644 --- a/client/client.go +++ b/client/client.go @@ -23,6 +23,7 @@ import ( "github.com/hashicorp/nomad/client/driver" "github.com/hashicorp/nomad/client/fingerprint" "github.com/hashicorp/nomad/client/stats" + cstructs "github.com/hashicorp/nomad/client/structs" "github.com/hashicorp/nomad/client/vaultclient" "github.com/hashicorp/nomad/command/agent/consul" "github.com/hashicorp/nomad/helper" @@ -931,33 +932,42 @@ func (c *Client) fingerprint() error { c.logger.Printf("[DEBUG] client: built-in fingerprints: %v", fingerprint.BuiltinFingerprints()) - var applied []string - var skipped []string + var detectedFingerprints []string + var skippedFingerprints []string for _, name := range fingerprint.BuiltinFingerprints() { // Skip modules that are not in the whitelist if it is enabled. if _, ok := whitelist[name]; whitelistEnabled && !ok { - skipped = append(skipped, name) + skippedFingerprints = append(skippedFingerprints, name) continue } // Skip modules that are in the blacklist if _, ok := blacklist[name]; ok { - skipped = append(skipped, name) + skippedFingerprints = append(skippedFingerprints, name) continue } + f, err := fingerprint.NewFingerprint(name, c.logger) if err != nil { return err } c.configLock.Lock() - applies, err := f.Fingerprint(c.config, c.config.Node) + request := &cstructs.FingerprintRequest{Config: c.config, Node: c.config.Node} + var response cstructs.FingerprintResponse + err = f.Fingerprint(request, &response) c.configLock.Unlock() if err != nil { return err } - if applies { - applied = append(applied, name) + + // log the fingerprinters which have been applied + if response.Detected { + detectedFingerprints = append(detectedFingerprints, name) } + + // add the diff found from each fingerprinter + c.updateNodeFromFingerprint(&response) + p, period := f.Periodic() if p { // TODO: If more periodic fingerprinters are added, then @@ -966,9 +976,10 @@ func (c *Client) fingerprint() error { go c.fingerprintPeriodic(name, f, period) } } - c.logger.Printf("[DEBUG] client: applied fingerprints %v", applied) - if len(skipped) != 0 { - c.logger.Printf("[DEBUG] client: fingerprint modules skipped due to white/blacklist: %v", skipped) + + c.logger.Printf("[DEBUG] client: detected fingerprints %v", detectedFingerprints) + if len(skippedFingerprints) != 0 { + c.logger.Printf("[DEBUG] client: fingerprint modules skipped due to white/blacklist: %v", skippedFingerprints) } return nil } @@ -980,10 +991,17 @@ func (c *Client) fingerprintPeriodic(name string, f fingerprint.Fingerprint, d t select { case <-time.After(d): c.configLock.Lock() - if _, err := f.Fingerprint(c.config, c.config.Node); err != nil { - c.logger.Printf("[DEBUG] client: periodic fingerprinting for %v failed: %v", name, err) - } + request := &cstructs.FingerprintRequest{Config: c.config, Node: c.config.Node} + var response cstructs.FingerprintResponse + err := f.Fingerprint(request, &response) c.configLock.Unlock() + + if err != nil { + c.logger.Printf("[DEBUG] client: periodic fingerprinting for %v failed: %v", name, err) + } else { + c.updateNodeFromFingerprint(&response) + } + case <-c.shutdownCh: return } @@ -997,19 +1015,19 @@ func (c *Client) setupDrivers() error { whitelistEnabled := len(whitelist) > 0 blacklist := c.config.ReadStringListToMap("driver.blacklist") - var avail []string - var skipped []string + var detectedDrivers []string + var skippedDrivers []string driverCtx := driver.NewDriverContext("", "", c.config, c.config.Node, c.logger, nil) for name := range driver.BuiltinDrivers { // Skip fingerprinting drivers that are not in the whitelist if it is // enabled. if _, ok := whitelist[name]; whitelistEnabled && !ok { - skipped = append(skipped, name) + skippedDrivers = append(skippedDrivers, name) continue } // Skip fingerprinting drivers that are in the blacklist if _, ok := blacklist[name]; ok { - skipped = append(skipped, name) + skippedDrivers = append(skippedDrivers, name) continue } @@ -1017,16 +1035,23 @@ func (c *Client) setupDrivers() error { if err != nil { return err } + c.configLock.Lock() - applies, err := d.Fingerprint(c.config, c.config.Node) + request := &cstructs.FingerprintRequest{Config: c.config, Node: c.config.Node} + var response cstructs.FingerprintResponse + err = d.Fingerprint(request, &response) c.configLock.Unlock() if err != nil { return err } - if applies { - avail = append(avail, name) + + // log the fingerprinters which have been applied + if response.Detected { + detectedDrivers = append(detectedDrivers, name) } + c.updateNodeFromFingerprint(&response) + p, period := d.Periodic() if p { go c.fingerprintPeriodic(name, d, period) @@ -1034,15 +1059,42 @@ func (c *Client) setupDrivers() error { } - c.logger.Printf("[DEBUG] client: available drivers %v", avail) - - if len(skipped) != 0 { - c.logger.Printf("[DEBUG] client: drivers skipped due to white/blacklist: %v", skipped) + c.logger.Printf("[DEBUG] client: detected drivers %v", detectedDrivers) + if len(skippedDrivers) > 0 { + c.logger.Printf("[DEBUG] client: drivers skipped due to white/blacklist: %v", skippedDrivers) } return nil } +// updateNodeFromFingerprint updates the node with the result of +// fingerprinting the node from the diff that was created +func (c *Client) updateNodeFromFingerprint(response *cstructs.FingerprintResponse) { + c.configLock.Lock() + defer c.configLock.Unlock() + for name, val := range response.Attributes { + if val == "" { + delete(c.config.Node.Attributes, name) + } else { + c.config.Node.Attributes[name] = val + } + } + + // update node links and resources from the diff created from + // fingerprinting + for name, val := range response.Links { + if val == "" { + delete(c.config.Node.Links, name) + } else { + c.config.Node.Links[name] = val + } + } + + if response.Resources != nil { + c.config.Node.Resources.Merge(response.Resources) + } +} + // retryIntv calculates a retry interval value given the base func (c *Client) retryIntv(base time.Duration) time.Duration { if c.config.DevMode { diff --git a/client/client_test.go b/client/client_test.go index 95ff480d3..9b16f93e7 100644 --- a/client/client_test.go +++ b/client/client_test.go @@ -14,6 +14,7 @@ import ( "github.com/hashicorp/consul/lib/freeport" memdb "github.com/hashicorp/go-memdb" "github.com/hashicorp/nomad/client/config" + "github.com/hashicorp/nomad/client/driver" "github.com/hashicorp/nomad/client/fingerprint" "github.com/hashicorp/nomad/command/agent/consul" "github.com/hashicorp/nomad/helper" @@ -252,6 +253,48 @@ func TestClient_HasNodeChanged(t *testing.T) { } } +func TestClient_Fingerprint_Periodic(t *testing.T) { + if _, ok := driver.BuiltinDrivers["mock_driver"]; !ok { + t.Skip(`test requires mock_driver; run with "-tags nomad_test"`) + } + t.Parallel() + + // these constants are only defined when nomad_test is enabled, so these fail + // our linter without explicit disabling. + c1 := testClient(t, func(c *config.Config) { + c.Options = map[string]string{ + driver.ShutdownPeriodicAfter: "true", // nolint: varcheck + driver.ShutdownPeriodicDuration: "3", // nolint: varcheck + } + }) + defer c1.Shutdown() + + node := c1.config.Node + mockDriverName := "driver.mock_driver" + + // Ensure the mock driver is registered on the client + testutil.WaitForResult(func() (bool, error) { + mockDriverStatus := node.Attributes[mockDriverName] + if mockDriverStatus == "" { + return false, fmt.Errorf("mock driver attribute should be set on the client") + } + return true, nil + }, func(err error) { + t.Fatalf("err: %v", err) + }) + + // Ensure that the client fingerprinter eventually removes this attribute + testutil.WaitForResult(func() (bool, error) { + mockDriverStatus := node.Attributes[mockDriverName] + if mockDriverStatus != "" { + return false, fmt.Errorf("mock driver attribute should not be set on the client") + } + return true, nil + }, func(err error) { + t.Fatalf("err: %v", err) + }) +} + func TestClient_Fingerprint_InWhitelist(t *testing.T) { t.Parallel() c := testClient(t, func(c *config.Config) { diff --git a/client/driver/docker.go b/client/driver/docker.go index 8a5451cb2..69318ee35 100644 --- a/client/driver/docker.go +++ b/client/driver/docker.go @@ -26,7 +26,6 @@ import ( "github.com/hashicorp/go-multierror" "github.com/hashicorp/go-plugin" "github.com/hashicorp/nomad/client/allocdir" - "github.com/hashicorp/nomad/client/config" "github.com/hashicorp/nomad/client/driver/env" "github.com/hashicorp/nomad/client/driver/executor" dstructs "github.com/hashicorp/nomad/client/driver/structs" @@ -180,51 +179,52 @@ type DockerVolumeDriverConfig struct { // DockerDriverConfig defines the user specified config block in a jobspec type DockerDriverConfig struct { - ImageName string `mapstructure:"image"` // Container's Image Name - LoadImage string `mapstructure:"load"` // LoadImage is a path to an image archive file - Command string `mapstructure:"command"` // The Command to run when the container starts up - Args []string `mapstructure:"args"` // The arguments to the Command - Entrypoint []string `mapstructure:"entrypoint"` // Override the containers entrypoint - IpcMode string `mapstructure:"ipc_mode"` // The IPC mode of the container - host and none - NetworkMode string `mapstructure:"network_mode"` // The network mode of the container - host, nat and none - NetworkAliases []string `mapstructure:"network_aliases"` // The network-scoped alias for the container - IPv4Address string `mapstructure:"ipv4_address"` // The container ipv4 address - IPv6Address string `mapstructure:"ipv6_address"` // the container ipv6 address - PidMode string `mapstructure:"pid_mode"` // The PID mode of the container - host and none - UTSMode string `mapstructure:"uts_mode"` // The UTS mode of the container - host and none - UsernsMode string `mapstructure:"userns_mode"` // The User namespace mode of the container - host and none - PortMapRaw []map[string]string `mapstructure:"port_map"` // - PortMap map[string]int `mapstructure:"-"` // A map of host port labels and the ports exposed on the container - Privileged bool `mapstructure:"privileged"` // Flag to run the container in privileged mode - SysctlRaw []map[string]string `mapstructure:"sysctl"` // - Sysctl map[string]string `mapstructure:"-"` // The sysctl custom configurations - UlimitRaw []map[string]string `mapstructure:"ulimit"` // - Ulimit []docker.ULimit `mapstructure:"-"` // The ulimit custom configurations - DNSServers []string `mapstructure:"dns_servers"` // DNS Server for containers - DNSSearchDomains []string `mapstructure:"dns_search_domains"` // DNS Search domains for containers - DNSOptions []string `mapstructure:"dns_options"` // DNS Options - ExtraHosts []string `mapstructure:"extra_hosts"` // Add host to /etc/hosts (host:IP) - Hostname string `mapstructure:"hostname"` // Hostname for containers - LabelsRaw []map[string]string `mapstructure:"labels"` // - Labels map[string]string `mapstructure:"-"` // Labels to set when the container starts up - Auth []DockerDriverAuth `mapstructure:"auth"` // Authentication credentials for a private Docker registry - AuthSoftFail bool `mapstructure:"auth_soft_fail"` // Soft-fail if auth creds are provided but fail - TTY bool `mapstructure:"tty"` // Allocate a Pseudo-TTY - Interactive bool `mapstructure:"interactive"` // Keep STDIN open even if not attached - ShmSize int64 `mapstructure:"shm_size"` // Size of /dev/shm of the container in bytes - WorkDir string `mapstructure:"work_dir"` // Working directory inside the container - Logging []DockerLoggingOpts `mapstructure:"logging"` // Logging options for syslog server - Volumes []string `mapstructure:"volumes"` // Host-Volumes to mount in, syntax: /path/to/host/directory:/destination/path/in/container - Mounts []DockerMount `mapstructure:"mounts"` // Docker volumes to mount - VolumeDriver string `mapstructure:"volume_driver"` // Docker volume driver used for the container's volumes - ForcePull bool `mapstructure:"force_pull"` // Always force pull before running image, useful if your tags are mutable - MacAddress string `mapstructure:"mac_address"` // Pin mac address to container - SecurityOpt []string `mapstructure:"security_opt"` // Flags to pass directly to security-opt - Devices []DockerDevice `mapstructure:"devices"` // To allow mounting USB or other serial control devices - CapAdd []string `mapstructure:"cap_add"` // Flags to pass directly to cap-add - CapDrop []string `mapstructure:"cap_drop"` // Flags to pass directly to cap-drop - ReadonlyRootfs bool `mapstructure:"readonly_rootfs"` // Mount the container’s root filesystem as read only - CPUHardLimit bool `mapstructure:"cpu_hard_limit"` // Enforce CPU hard limit. + ImageName string `mapstructure:"image"` // Container's Image Name + LoadImage string `mapstructure:"load"` // LoadImage is a path to an image archive file + Command string `mapstructure:"command"` // The Command to run when the container starts up + Args []string `mapstructure:"args"` // The arguments to the Command + Entrypoint []string `mapstructure:"entrypoint"` // Override the containers entrypoint + IpcMode string `mapstructure:"ipc_mode"` // The IPC mode of the container - host and none + NetworkMode string `mapstructure:"network_mode"` // The network mode of the container - host, nat and none + NetworkAliases []string `mapstructure:"network_aliases"` // The network-scoped alias for the container + IPv4Address string `mapstructure:"ipv4_address"` // The container ipv4 address + IPv6Address string `mapstructure:"ipv6_address"` // the container ipv6 address + PidMode string `mapstructure:"pid_mode"` // The PID mode of the container - host and none + UTSMode string `mapstructure:"uts_mode"` // The UTS mode of the container - host and none + UsernsMode string `mapstructure:"userns_mode"` // The User namespace mode of the container - host and none + PortMapRaw []map[string]string `mapstructure:"port_map"` // + PortMap map[string]int `mapstructure:"-"` // A map of host port labels and the ports exposed on the container + Privileged bool `mapstructure:"privileged"` // Flag to run the container in privileged mode + SysctlRaw []map[string]string `mapstructure:"sysctl"` // + Sysctl map[string]string `mapstructure:"-"` // The sysctl custom configurations + UlimitRaw []map[string]string `mapstructure:"ulimit"` // + Ulimit []docker.ULimit `mapstructure:"-"` // The ulimit custom configurations + DNSServers []string `mapstructure:"dns_servers"` // DNS Server for containers + DNSSearchDomains []string `mapstructure:"dns_search_domains"` // DNS Search domains for containers + DNSOptions []string `mapstructure:"dns_options"` // DNS Options + ExtraHosts []string `mapstructure:"extra_hosts"` // Add host to /etc/hosts (host:IP) + Hostname string `mapstructure:"hostname"` // Hostname for containers + LabelsRaw []map[string]string `mapstructure:"labels"` // + Labels map[string]string `mapstructure:"-"` // Labels to set when the container starts up + Auth []DockerDriverAuth `mapstructure:"auth"` // Authentication credentials for a private Docker registry + AuthSoftFail bool `mapstructure:"auth_soft_fail"` // Soft-fail if auth creds are provided but fail + TTY bool `mapstructure:"tty"` // Allocate a Pseudo-TTY + Interactive bool `mapstructure:"interactive"` // Keep STDIN open even if not attached + ShmSize int64 `mapstructure:"shm_size"` // Size of /dev/shm of the container in bytes + WorkDir string `mapstructure:"work_dir"` // Working directory inside the container + Logging []DockerLoggingOpts `mapstructure:"logging"` // Logging options for syslog server + Volumes []string `mapstructure:"volumes"` // Host-Volumes to mount in, syntax: /path/to/host/directory:/destination/path/in/container + Mounts []DockerMount `mapstructure:"mounts"` // Docker volumes to mount + VolumeDriver string `mapstructure:"volume_driver"` // Docker volume driver used for the container's volumes + ForcePull bool `mapstructure:"force_pull"` // Always force pull before running image, useful if your tags are mutable + MacAddress string `mapstructure:"mac_address"` // Pin mac address to container + SecurityOpt []string `mapstructure:"security_opt"` // Flags to pass directly to security-opt + Devices []DockerDevice `mapstructure:"devices"` // To allow mounting USB or other serial control devices + CapAdd []string `mapstructure:"cap_add"` // Flags to pass directly to cap-add + CapDrop []string `mapstructure:"cap_drop"` // Flags to pass directly to cap-drop + ReadonlyRootfs bool `mapstructure:"readonly_rootfs"` // Mount the container’s root filesystem as read only + AdvertiseIPv6Address bool `mapstructure:"advertise_ipv6_address"` // Flag to use the GlobalIPv6Address from the container as the detected IP + CPUHardLimit bool `mapstructure:"cpu_hard_limit"` // Enforce CPU hard limit. } func sliceMergeUlimit(ulimitsRaw map[string]string) ([]docker.ULimit, error) { @@ -485,16 +485,15 @@ func NewDockerDriver(ctx *DriverContext) Driver { return &DockerDriver{DriverContext: *ctx} } -func (d *DockerDriver) Fingerprint(cfg *config.Config, node *structs.Node) (bool, error) { - // Initialize docker API clients +func (d *DockerDriver) Fingerprint(req *cstructs.FingerprintRequest, resp *cstructs.FingerprintResponse) error { client, _, err := d.dockerClients() if err != nil { if d.fingerprintSuccess == nil || *d.fingerprintSuccess { d.logger.Printf("[INFO] driver.docker: failed to initialize client: %s", err) } - delete(node.Attributes, dockerDriverAttr) d.fingerprintSuccess = helper.BoolToPtr(false) - return false, nil + resp.RemoveAttribute(dockerDriverAttr) + return nil } // This is the first operation taken on the client so we'll try to @@ -502,25 +501,26 @@ func (d *DockerDriver) Fingerprint(cfg *config.Config, node *structs.Node) (bool // Docker isn't available so we'll simply disable the docker driver. env, err := client.Version() if err != nil { - delete(node.Attributes, dockerDriverAttr) if d.fingerprintSuccess == nil || *d.fingerprintSuccess { d.logger.Printf("[DEBUG] driver.docker: could not connect to docker daemon at %s: %s", client.Endpoint(), err) } d.fingerprintSuccess = helper.BoolToPtr(false) - return false, nil + resp.RemoveAttribute(dockerDriverAttr) + return nil } - node.Attributes[dockerDriverAttr] = "1" - node.Attributes["driver.docker.version"] = env.Get("Version") + resp.AddAttribute(dockerDriverAttr, "1") + resp.AddAttribute("driver.docker.version", env.Get("Version")) + resp.Detected = true privileged := d.config.ReadBoolDefault(dockerPrivilegedConfigOption, false) if privileged { - node.Attributes[dockerPrivilegedConfigOption] = "1" + resp.AddAttribute(dockerPrivilegedConfigOption, "1") } // Advertise if this node supports Docker volumes if d.config.ReadBoolDefault(dockerVolumesConfigOption, dockerVolumesConfigDefault) { - node.Attributes["driver."+dockerVolumesConfigOption] = "1" + resp.AddAttribute("driver."+dockerVolumesConfigOption, "1") } // Detect bridge IP address - #2785 @@ -538,7 +538,7 @@ func (d *DockerDriver) Fingerprint(cfg *config.Config, node *structs.Node) (bool } if n.IPAM.Config[0].Gateway != "" { - node.Attributes["driver.docker.bridge_ip"] = n.IPAM.Config[0].Gateway + resp.AddAttribute("driver.docker.bridge_ip", n.IPAM.Config[0].Gateway) } else if d.fingerprintSuccess == nil { // Docker 17.09.0-ce dropped the Gateway IP from the bridge network // See https://github.com/moby/moby/issues/32648 @@ -549,7 +549,7 @@ func (d *DockerDriver) Fingerprint(cfg *config.Config, node *structs.Node) (bool } d.fingerprintSuccess = helper.BoolToPtr(true) - return true, nil + return nil } // Validate is used to validate the driver configuration @@ -682,6 +682,9 @@ func (d *DockerDriver) Validate(config map[string]interface{}) error { "readonly_rootfs": { Type: fields.TypeBool, }, + "advertise_ipv6_address": { + Type: fields.TypeBool, + }, "cpu_hard_limit": { Type: fields.TypeBool, }, @@ -895,6 +898,10 @@ func (d *DockerDriver) detectIP(c *docker.Container) (string, bool) { } ip = net.IPAddress + if d.driverConfig.AdvertiseIPv6Address { + ip = net.GlobalIPv6Address + auto = true + } ipName = name // Don't auto-advertise IPs for default networks (bridge on diff --git a/client/driver/docker_test.go b/client/driver/docker_test.go index 6dc5d97c5..511bc31a4 100644 --- a/client/driver/docker_test.go +++ b/client/driver/docker_test.go @@ -171,17 +171,31 @@ func TestDockerDriver_Fingerprint(t *testing.T) { node := &structs.Node{ Attributes: make(map[string]string), } - apply, err := d.Fingerprint(&config.Config{}, node) + + request := &cstructs.FingerprintRequest{Config: &config.Config{}, Node: node} + var response cstructs.FingerprintResponse + err := d.Fingerprint(request, &response) if err != nil { t.Fatalf("err: %v", err) } - if apply != testutil.DockerIsConnected(t) { + + attributes := response.Attributes + if testutil.DockerIsConnected(t) && attributes["driver.docker"] == "" { t.Fatalf("Fingerprinter should detect when docker is available") } - if node.Attributes["driver.docker"] != "1" { + + if attributes["driver.docker"] != "1" { t.Log("Docker daemon not available. The remainder of the docker tests will be skipped.") + } else { + + // if docker is available, make sure that the response is tagged as + // applicable + if !response.Detected { + t.Fatalf("expected response to be applicable") + } } - t.Logf("Found docker version %s", node.Attributes["driver.docker.version"]) + + t.Logf("Found docker version %s", attributes["driver.docker.version"]) } // TestDockerDriver_Fingerprint_Bridge asserts that if Docker is running we set @@ -210,18 +224,31 @@ func TestDockerDriver_Fingerprint_Bridge(t *testing.T) { conf := testConfig(t) conf.Node = mock.Node() dd := NewDockerDriver(NewDriverContext("", "", conf, conf.Node, testLogger(), nil)) - ok, err := dd.Fingerprint(conf, conf.Node) + + request := &cstructs.FingerprintRequest{Config: conf, Node: conf.Node} + var response cstructs.FingerprintResponse + err = dd.Fingerprint(request, &response) if err != nil { t.Fatalf("error fingerprinting docker: %v", err) } - if !ok { + + if !response.Detected { + t.Fatalf("expected response to be applicable") + } + + attributes := response.Attributes + if attributes == nil { + t.Fatalf("expected attributes to be set") + } + + if attributes["driver.docker"] == "" { t.Fatalf("expected Docker to be enabled but false was returned") } - if found := conf.Node.Attributes["driver.docker.bridge_ip"]; found != expectedAddr { + if found := attributes["driver.docker.bridge_ip"]; found != expectedAddr { t.Fatalf("expected bridge ip %q but found: %q", expectedAddr, found) } - t.Logf("docker bridge ip: %q", conf.Node.Attributes["driver.docker.bridge_ip"]) + t.Logf("docker bridge ip: %q", attributes["driver.docker.bridge_ip"]) } func TestDockerDriver_StartOpen_Wait(t *testing.T) { @@ -2269,3 +2296,85 @@ func TestDockerDriver_ReadonlyRootfs(t *testing.T) { assert.True(t, container.HostConfig.ReadonlyRootfs, "ReadonlyRootfs option not set") } + +func TestDockerDriver_AdvertiseIPv6Address(t *testing.T) { + if !tu.IsTravis() { + t.Parallel() + } + if !testutil.DockerIsConnected(t) { + t.Skip("Docker not connected") + } + + expectedPrefix := "2001:db8:1::242:ac11" + expectedAdvertise := true + task := &structs.Task{ + Name: "nc-demo", + Driver: "docker", + Config: map[string]interface{}{ + "image": "busybox", + "load": "busybox.tar", + "command": "/bin/nc", + "args": []string{"-l", "127.0.0.1", "-p", "0"}, + "advertise_ipv6_address": expectedAdvertise, + }, + Resources: &structs.Resources{ + MemoryMB: 256, + CPU: 512, + }, + LogConfig: &structs.LogConfig{ + MaxFiles: 10, + MaxFileSizeMB: 10, + }, + } + + client := newTestDockerClient(t) + + // Make sure IPv6 is enabled + net, err := client.NetworkInfo("bridge") + if err != nil { + t.Skip("error retrieving bridge network information, skipping") + } + if net == nil || !net.EnableIPv6 { + t.Skip("IPv6 not enabled on bridge network, skipping") + } + + tctx := testDockerDriverContexts(t, task) + driver := NewDockerDriver(tctx.DriverCtx) + copyImage(t, tctx.ExecCtx.TaskDir, "busybox.tar") + defer tctx.AllocDir.Destroy() + + presp, err := driver.Prestart(tctx.ExecCtx, task) + defer driver.Cleanup(tctx.ExecCtx, presp.CreatedResources) + if err != nil { + t.Fatalf("Error in prestart: %v", err) + } + + sresp, err := driver.Start(tctx.ExecCtx, task) + if err != nil { + t.Fatalf("Error in start: %v", err) + } + + if sresp.Handle == nil { + t.Fatalf("handle is nil\nStack\n%s", debug.Stack()) + } + + assert.Equal(t, expectedAdvertise, sresp.Network.AutoAdvertise, "Wrong autoadvertise. Expect: %s, got: %s", expectedAdvertise, sresp.Network.AutoAdvertise) + + if !strings.HasPrefix(sresp.Network.IP, expectedPrefix) { + t.Fatalf("Got IP address %q want ip address with prefix %q", sresp.Network.IP, expectedPrefix) + } + + defer sresp.Handle.Kill() + handle := sresp.Handle.(*DockerHandle) + + waitForExist(t, client, handle) + + container, err := client.InspectContainer(handle.ContainerID()) + if err != nil { + t.Fatalf("Error inspecting container: %v", err) + } + + if !strings.HasPrefix(container.NetworkSettings.GlobalIPv6Address, expectedPrefix) { + t.Fatalf("Got GlobalIPv6address %s want GlobalIPv6address with prefix %s", expectedPrefix, container.NetworkSettings.GlobalIPv6Address) + } +} diff --git a/client/driver/exec_default.go b/client/driver/exec_default.go index 2f1e26787..05a609e9d 100644 --- a/client/driver/exec_default.go +++ b/client/driver/exec_default.go @@ -3,12 +3,12 @@ package driver import ( - "github.com/hashicorp/nomad/client/config" + cstructs "github.com/hashicorp/nomad/client/structs" "github.com/hashicorp/nomad/helper" - "github.com/hashicorp/nomad/nomad/structs" ) -func (d *ExecDriver) Fingerprint(cfg *config.Config, node *structs.Node) (bool, error) { +func (d *ExecDriver) Fingerprint(req *cstructs.FingerprintRequest, resp *cstructs.FingerprintResponse) error { d.fingerprintSuccess = helper.BoolToPtr(false) - return false, nil + resp.Detected = true + return nil } diff --git a/client/driver/exec_linux.go b/client/driver/exec_linux.go index ab3203a49..138a92d75 100644 --- a/client/driver/exec_linux.go +++ b/client/driver/exec_linux.go @@ -1,9 +1,8 @@ package driver import ( - "github.com/hashicorp/nomad/client/config" + cstructs "github.com/hashicorp/nomad/client/structs" "github.com/hashicorp/nomad/helper" - "github.com/hashicorp/nomad/nomad/structs" "golang.org/x/sys/unix" ) @@ -13,28 +12,31 @@ const ( execDriverAttr = "driver.exec" ) -func (d *ExecDriver) Fingerprint(cfg *config.Config, node *structs.Node) (bool, error) { +func (d *ExecDriver) Fingerprint(req *cstructs.FingerprintRequest, resp *cstructs.FingerprintResponse) error { + // The exec driver will be detected in every case + resp.Detected = true + // Only enable if cgroups are available and we are root - if !cgroupsMounted(node) { + if !cgroupsMounted(req.Node) { if d.fingerprintSuccess == nil || *d.fingerprintSuccess { - d.logger.Printf("[DEBUG] driver.exec: cgroups unavailable, disabling") + d.logger.Printf("[INFO] driver.exec: cgroups unavailable, disabling") } d.fingerprintSuccess = helper.BoolToPtr(false) - delete(node.Attributes, execDriverAttr) - return false, nil + resp.RemoveAttribute(execDriverAttr) + return nil } else if unix.Geteuid() != 0 { if d.fingerprintSuccess == nil || *d.fingerprintSuccess { d.logger.Printf("[DEBUG] driver.exec: must run as root user, disabling") } - delete(node.Attributes, execDriverAttr) d.fingerprintSuccess = helper.BoolToPtr(false) - return false, nil + resp.RemoveAttribute(execDriverAttr) + return nil } if d.fingerprintSuccess == nil || !*d.fingerprintSuccess { d.logger.Printf("[DEBUG] driver.exec: exec driver is enabled") } - node.Attributes[execDriverAttr] = "1" + resp.AddAttribute(execDriverAttr, "1") d.fingerprintSuccess = helper.BoolToPtr(true) - return true, nil + return nil } diff --git a/client/driver/exec_test.go b/client/driver/exec_test.go index cd6365b8f..d854c56a0 100644 --- a/client/driver/exec_test.go +++ b/client/driver/exec_test.go @@ -13,6 +13,7 @@ import ( "github.com/hashicorp/nomad/client/config" "github.com/hashicorp/nomad/client/driver/env" + cstructs "github.com/hashicorp/nomad/client/structs" "github.com/hashicorp/nomad/nomad/structs" "github.com/hashicorp/nomad/testutil" @@ -37,14 +38,19 @@ func TestExecDriver_Fingerprint(t *testing.T) { "unique.cgroup.mountpoint": "/sys/fs/cgroup", }, } - apply, err := d.Fingerprint(&config.Config{}, node) + + request := &cstructs.FingerprintRequest{Config: &config.Config{}, Node: node} + var response cstructs.FingerprintResponse + err := d.Fingerprint(request, &response) if err != nil { t.Fatalf("err: %v", err) } - if !apply { - t.Fatalf("should apply") + + if !response.Detected { + t.Fatalf("expected response to be applicable") } - if node.Attributes["driver.exec"] == "" { + + if response.Attributes == nil || response.Attributes["driver.exec"] == "" { t.Fatalf("missing driver") } } diff --git a/client/driver/java.go b/client/driver/java.go index 8c162e0cd..3c4b31958 100644 --- a/client/driver/java.go +++ b/client/driver/java.go @@ -18,7 +18,6 @@ import ( "github.com/hashicorp/go-plugin" "github.com/mitchellh/mapstructure" - "github.com/hashicorp/nomad/client/config" "github.com/hashicorp/nomad/client/driver/env" "github.com/hashicorp/nomad/client/driver/executor" dstructs "github.com/hashicorp/nomad/client/driver/structs" @@ -112,15 +111,16 @@ func (d *JavaDriver) Abilities() DriverAbilities { } } -func (d *JavaDriver) Fingerprint(cfg *config.Config, node *structs.Node) (bool, error) { +func (d *JavaDriver) Fingerprint(req *cstructs.FingerprintRequest, resp *cstructs.FingerprintResponse) error { // Only enable if we are root and cgroups are mounted when running on linux systems. - if runtime.GOOS == "linux" && (syscall.Geteuid() != 0 || !cgroupsMounted(node)) { + if runtime.GOOS == "linux" && (syscall.Geteuid() != 0 || !cgroupsMounted(req.Node)) { if d.fingerprintSuccess == nil || *d.fingerprintSuccess { - d.logger.Printf("[DEBUG] driver.java: root privileges and mounted cgroups required on linux, disabling") + d.logger.Printf("[INFO] driver.java: root privileges and mounted cgroups required on linux, disabling") } - delete(node.Attributes, "driver.java") d.fingerprintSuccess = helper.BoolToPtr(false) - return false, nil + resp.RemoveAttribute(javaDriverAttr) + resp.Detected = true + return nil } // Find java version @@ -132,9 +132,9 @@ func (d *JavaDriver) Fingerprint(cfg *config.Config, node *structs.Node) (bool, err := cmd.Run() if err != nil { // assume Java wasn't found - delete(node.Attributes, javaDriverAttr) d.fingerprintSuccess = helper.BoolToPtr(false) - return false, nil + resp.RemoveAttribute(javaDriverAttr) + return nil } // 'java -version' returns output on Stderr typically. @@ -152,9 +152,9 @@ func (d *JavaDriver) Fingerprint(cfg *config.Config, node *structs.Node) (bool, if d.fingerprintSuccess == nil || *d.fingerprintSuccess { d.logger.Println("[WARN] driver.java: error parsing Java version information, aborting") } - delete(node.Attributes, javaDriverAttr) d.fingerprintSuccess = helper.BoolToPtr(false) - return false, nil + resp.RemoveAttribute(javaDriverAttr) + return nil } // Assume 'java -version' returns 3 lines: @@ -166,13 +166,14 @@ func (d *JavaDriver) Fingerprint(cfg *config.Config, node *structs.Node) (bool, versionString := info[0] versionString = strings.TrimPrefix(versionString, "java version ") versionString = strings.Trim(versionString, "\"") - node.Attributes[javaDriverAttr] = "1" - node.Attributes["driver.java.version"] = versionString - node.Attributes["driver.java.runtime"] = info[1] - node.Attributes["driver.java.vm"] = info[2] + resp.AddAttribute(javaDriverAttr, "1") + resp.AddAttribute("driver.java.version", versionString) + resp.AddAttribute("driver.java.runtime", info[1]) + resp.AddAttribute("driver.java.vm", info[2]) d.fingerprintSuccess = helper.BoolToPtr(true) + resp.Detected = true - return true, nil + return nil } func (d *JavaDriver) Prestart(*ExecContext, *structs.Task) (*PrestartResponse, error) { diff --git a/client/driver/java_test.go b/client/driver/java_test.go index b1f6dc3f1..f273869a7 100644 --- a/client/driver/java_test.go +++ b/client/driver/java_test.go @@ -11,6 +11,7 @@ import ( "time" "github.com/hashicorp/nomad/client/config" + cstructs "github.com/hashicorp/nomad/client/structs" "github.com/hashicorp/nomad/nomad/structs" "github.com/hashicorp/nomad/testutil" "github.com/stretchr/testify/assert" @@ -49,14 +50,19 @@ func TestJavaDriver_Fingerprint(t *testing.T) { "unique.cgroup.mountpoint": "/sys/fs/cgroups", }, } - apply, err := d.Fingerprint(&config.Config{}, node) + + request := &cstructs.FingerprintRequest{Config: &config.Config{}, Node: node} + var response cstructs.FingerprintResponse + err := d.Fingerprint(request, &response) if err != nil { t.Fatalf("err: %v", err) } - if apply != javaLocated() { - t.Fatalf("Fingerprinter should detect Java when it is installed") + + if !response.Detected { + t.Fatalf("expected response to be applicable") } - if node.Attributes["driver.java"] != "1" { + + if response.Attributes["driver.java"] != "1" && javaLocated() { if v, ok := osJavaDriverSupport[runtime.GOOS]; v && ok { t.Fatalf("missing java driver") } else { @@ -64,7 +70,7 @@ func TestJavaDriver_Fingerprint(t *testing.T) { } } for _, key := range []string{"driver.java.version", "driver.java.runtime", "driver.java.vm"} { - if node.Attributes[key] == "" { + if response.Attributes[key] == "" { t.Fatalf("missing driver key (%s)", key) } } diff --git a/client/driver/lxc.go b/client/driver/lxc.go index 3c5c19fff..5f724f98b 100644 --- a/client/driver/lxc.go +++ b/client/driver/lxc.go @@ -14,7 +14,6 @@ import ( "syscall" "time" - "github.com/hashicorp/nomad/client/config" "github.com/hashicorp/nomad/client/fingerprint" "github.com/hashicorp/nomad/client/stats" "github.com/hashicorp/nomad/helper/fields" @@ -184,24 +183,27 @@ func (d *LxcDriver) FSIsolation() cstructs.FSIsolation { } // Fingerprint fingerprints the lxc driver configuration -func (d *LxcDriver) Fingerprint(cfg *config.Config, node *structs.Node) (bool, error) { +func (d *LxcDriver) Fingerprint(req *cstructs.FingerprintRequest, resp *cstructs.FingerprintResponse) error { + cfg := req.Config + enabled := cfg.ReadBoolDefault(lxcConfigOption, true) if !enabled && !cfg.DevMode { - return false, nil + return nil } version := lxc.Version() if version == "" { - return false, nil + return nil } - node.Attributes["driver.lxc.version"] = version - node.Attributes["driver.lxc"] = "1" + resp.AddAttribute("driver.lxc.version", version) + resp.AddAttribute("driver.lxc", "1") + resp.Detected = true // Advertise if this node supports lxc volumes if d.config.ReadBoolDefault(lxcVolumesConfigOption, lxcVolumesConfigDefault) { - node.Attributes["driver."+lxcVolumesConfigOption] = "1" + resp.AddAttribute("driver."+lxcVolumesConfigOption, "1") } - return true, nil + return nil } func (d *LxcDriver) Prestart(*ExecContext, *structs.Task) (*PrestartResponse, error) { @@ -210,9 +212,20 @@ func (d *LxcDriver) Prestart(*ExecContext, *structs.Task) (*PrestartResponse, er // Start starts the LXC Driver func (d *LxcDriver) Start(ctx *ExecContext, task *structs.Task) (*StartResponse, error) { + sresp, err, errCleanup := d.startWithCleanup(ctx, task) + if err != nil { + if cleanupErr := errCleanup(); cleanupErr != nil { + d.logger.Printf("[ERR] error occurred while cleaning up from error in Start: %v", cleanupErr) + } + } + return sresp, err +} + +func (d *LxcDriver) startWithCleanup(ctx *ExecContext, task *structs.Task) (*StartResponse, error, func() error) { + noCleanup := func() error { return nil } var driverConfig LxcDriverConfig if err := mapstructure.WeakDecode(task.Config, &driverConfig); err != nil { - return nil, err + return nil, err, noCleanup } lxcPath := lxc.DefaultConfigPath() if path := d.config.Read("driver.lxc.path"); path != "" { @@ -222,7 +235,7 @@ func (d *LxcDriver) Start(ctx *ExecContext, task *structs.Task) (*StartResponse, containerName := fmt.Sprintf("%s-%s", task.Name, d.DriverContext.allocID) c, err := lxc.NewContainer(containerName, lxcPath) if err != nil { - return nil, fmt.Errorf("unable to initialize container: %v", err) + return nil, fmt.Errorf("unable to initialize container: %v", err), noCleanup } var verbosity lxc.Verbosity @@ -232,7 +245,7 @@ func (d *LxcDriver) Start(ctx *ExecContext, task *structs.Task) (*StartResponse, case "", "quiet": verbosity = lxc.Quiet default: - return nil, fmt.Errorf("lxc driver config 'verbosity' can only be either quiet or verbose") + return nil, fmt.Errorf("lxc driver config 'verbosity' can only be either quiet or verbose"), noCleanup } c.SetVerbosity(verbosity) @@ -249,7 +262,7 @@ func (d *LxcDriver) Start(ctx *ExecContext, task *structs.Task) (*StartResponse, case "", "error": logLevel = lxc.ERROR default: - return nil, fmt.Errorf("lxc driver config 'log_level' can only be trace, debug, info, warn or error") + return nil, fmt.Errorf("lxc driver config 'log_level' can only be trace, debug, info, warn or error"), noCleanup } c.SetLogLevel(logLevel) @@ -267,12 +280,12 @@ func (d *LxcDriver) Start(ctx *ExecContext, task *structs.Task) (*StartResponse, } if err := c.Create(options); err != nil { - return nil, fmt.Errorf("unable to create container: %v", err) + return nil, fmt.Errorf("unable to create container: %v", err), noCleanup } // Set the network type to none if err := c.SetConfigItem("lxc.network.type", "none"); err != nil { - return nil, fmt.Errorf("error setting network type configuration: %v", err) + return nil, fmt.Errorf("error setting network type configuration: %v", err), c.Destroy } // Bind mount the shared alloc dir and task local dir in the container @@ -290,7 +303,7 @@ func (d *LxcDriver) Start(ctx *ExecContext, task *structs.Task) (*StartResponse, if filepath.IsAbs(paths[0]) { if !volumesEnabled { - return nil, fmt.Errorf("absolute bind-mount volume in config but '%v' is false", lxcVolumesConfigOption) + return nil, fmt.Errorf("absolute bind-mount volume in config but '%v' is false", lxcVolumesConfigOption), c.Destroy } } else { // Relative source paths are treated as relative to alloc dir @@ -302,21 +315,28 @@ func (d *LxcDriver) Start(ctx *ExecContext, task *structs.Task) (*StartResponse, 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) + return nil, fmt.Errorf("error setting bind mount %q error: %v", mnt, err), c.Destroy } } // Start the container if err := c.Start(); err != nil { - return nil, fmt.Errorf("unable to start container: %v", err) + return nil, fmt.Errorf("unable to start container: %v", err), c.Destroy + } + + stopAndDestroyCleanup := func() error { + if err := c.Stop(); err != nil { + return err + } + return c.Destroy() } // Set the resource limits if err := c.SetMemoryLimit(lxc.ByteSize(task.Resources.MemoryMB) * lxc.MB); err != nil { - return nil, fmt.Errorf("unable to set memory limits: %v", err) + return nil, fmt.Errorf("unable to set memory limits: %v", err), stopAndDestroyCleanup } if err := c.SetCgroupItem("cpu.shares", strconv.Itoa(task.Resources.CPU)); err != nil { - return nil, fmt.Errorf("unable to set cpu shares: %v", err) + return nil, fmt.Errorf("unable to set cpu shares: %v", err), stopAndDestroyCleanup } h := lxcDriverHandle{ @@ -335,7 +355,7 @@ func (d *LxcDriver) Start(ctx *ExecContext, task *structs.Task) (*StartResponse, go h.run() - return &StartResponse{Handle: &h}, nil + return &StartResponse{Handle: &h}, nil, noCleanup } func (d *LxcDriver) Cleanup(*ExecContext, *CreatedResources) error { return nil } diff --git a/client/driver/lxc_test.go b/client/driver/lxc_test.go index ddc78193c..7b81c63fb 100644 --- a/client/driver/lxc_test.go +++ b/client/driver/lxc_test.go @@ -13,6 +13,7 @@ import ( "time" "github.com/hashicorp/nomad/client/config" + cstructs "github.com/hashicorp/nomad/client/structs" ctestutil "github.com/hashicorp/nomad/client/testutil" "github.com/hashicorp/nomad/nomad/structs" "github.com/hashicorp/nomad/testutil" @@ -38,23 +39,34 @@ func TestLxcDriver_Fingerprint(t *testing.T) { node := &structs.Node{ Attributes: map[string]string{}, } - apply, err := d.Fingerprint(&config.Config{}, node) - if err != nil { - t.Fatalf("err: %v", err) - } - if !apply { - t.Fatalf("should apply by default") + + // test with an empty config + { + request := &cstructs.FingerprintRequest{Config: &config.Config{}, Node: node} + var response cstructs.FingerprintResponse + err := d.Fingerprint(request, &response) + if err != nil { + t.Fatalf("err: %v", err) + } } - apply, err = d.Fingerprint(&config.Config{Options: map[string]string{lxcConfigOption: "0"}}, node) - if err != nil { - t.Fatalf("err: %v", err) - } - if apply { - t.Fatalf("should not apply with config") - } - if node.Attributes["driver.lxc"] == "" { - t.Fatalf("missing driver") + // test when lxc is enable din the config + { + conf := &config.Config{Options: map[string]string{lxcConfigOption: "1"}} + request := &cstructs.FingerprintRequest{Config: conf, Node: node} + var response cstructs.FingerprintResponse + err := d.Fingerprint(request, &response) + if err != nil { + t.Fatalf("err: %v", err) + } + + if !response.Detected { + t.Fatalf("expected response to be applicable") + } + + if response.Attributes["driver.lxc"] == "" { + t.Fatalf("missing driver") + } } } @@ -310,6 +322,7 @@ func TestLxcDriver_Start_NoVolumes(t *testing.T) { ctx := testDriverContexts(t, task) defer ctx.AllocDir.Destroy() + // set lxcVolumesConfigOption to false to disallow absolute paths as the source for the bind mount ctx.DriverCtx.config.Options = map[string]string{lxcVolumesConfigOption: "false"} d := NewLxcDriver(ctx.DriverCtx) @@ -317,8 +330,19 @@ func TestLxcDriver_Start_NoVolumes(t *testing.T) { if _, err := d.Prestart(ctx.ExecCtx, task); err != nil { t.Fatalf("prestart err: %v", err) } + + // expect the "absolute bind-mount volume in config.. " error _, err := d.Start(ctx.ExecCtx, task) if err == nil { t.Fatalf("expected error in start, got nil.") } + + // Because the container was created but not started before + // the expected error, we can test that the destroy-only + // cleanup is done here. + containerName := fmt.Sprintf("%s-%s", task.Name, ctx.DriverCtx.allocID) + if err := exec.Command("bash", "-c", fmt.Sprintf("lxc-ls -1 | grep -q %s", containerName)).Run(); err == nil { + t.Fatalf("error, container '%s' is still around", containerName) + } + } diff --git a/client/driver/mock_driver.go b/client/driver/mock_driver.go index 492794854..15cc56b5b 100644 --- a/client/driver/mock_driver.go +++ b/client/driver/mock_driver.go @@ -15,13 +15,23 @@ import ( "github.com/mitchellh/mapstructure" - "github.com/hashicorp/nomad/client/config" dstructs "github.com/hashicorp/nomad/client/driver/structs" - "github.com/hashicorp/nomad/client/fingerprint" cstructs "github.com/hashicorp/nomad/client/structs" "github.com/hashicorp/nomad/nomad/structs" ) +const ( + // ShutdownPeriodicAfter is a config key that can be used during tests to + // "stop" a previously-functioning driver, allowing for testing of periodic + // drivers and fingerprinters + ShutdownPeriodicAfter = "test.shutdown_periodic_after" + + // ShutdownPeriodicDuration is a config option that can be used during tests + // to "stop" a previously functioning driver after the specified duration + // (specified in seconds) for testing of periodic drivers and fingerprinters. + ShutdownPeriodicDuration = "test.shutdown_periodic_duration" +) + // Add the mock driver to the list of builtin drivers func init() { BuiltinDrivers["mock_driver"] = NewMockDriver @@ -78,14 +88,29 @@ type MockDriverConfig struct { // MockDriver is a driver which is used for testing purposes type MockDriver struct { DriverContext - fingerprint.StaticFingerprinter cleanupFailNum int + + // shutdownFingerprintTime is the time up to which the driver will be up + shutdownFingerprintTime time.Time } // NewMockDriver is a factory method which returns a new Mock Driver func NewMockDriver(ctx *DriverContext) Driver { - return &MockDriver{DriverContext: *ctx} + md := &MockDriver{DriverContext: *ctx} + + // if the shutdown configuration options are set, start the timer here. + // This config option defaults to false + if ctx.config != nil && ctx.config.ReadBoolDefault(ShutdownPeriodicAfter, false) { + duration, err := ctx.config.ReadInt(ShutdownPeriodicDuration) + if err != nil { + errMsg := fmt.Sprintf("unable to read config option for shutdown_periodic_duration %s, got err %s", duration, err.Error()) + panic(errMsg) + } + md.shutdownFingerprintTime = time.Now().Add(time.Second * time.Duration(duration)) + } + + return md } func (d *MockDriver) Abilities() DriverAbilities { @@ -194,9 +219,18 @@ func (m *MockDriver) Validate(map[string]interface{}) error { } // Fingerprint fingerprints a node and returns if MockDriver is enabled -func (m *MockDriver) Fingerprint(cfg *config.Config, node *structs.Node) (bool, error) { - node.Attributes["driver.mock_driver"] = "1" - return true, nil +func (m *MockDriver) Fingerprint(req *cstructs.FingerprintRequest, resp *cstructs.FingerprintResponse) error { + switch { + // If the driver is configured to shut down after a period of time, and the + // current time is after the time which the node should shut down, simulate + // driver failure + case !m.shutdownFingerprintTime.IsZero() && time.Now().After(m.shutdownFingerprintTime): + resp.RemoveAttribute("driver.mock_driver") + default: + resp.AddAttribute("driver.mock_driver", "1") + resp.Detected = true + } + return nil } // MockDriverHandle is a driver handler which supervises a mock task @@ -339,3 +373,8 @@ func (h *mockDriverHandle) run() { } } } + +// When testing, poll for updates +func (m *MockDriver) Periodic() (bool, time.Duration) { + return true, 500 * time.Millisecond +} diff --git a/client/driver/qemu.go b/client/driver/qemu.go index f256c829c..158628672 100644 --- a/client/driver/qemu.go +++ b/client/driver/qemu.go @@ -17,7 +17,6 @@ import ( "github.com/coreos/go-semver/semver" plugin "github.com/hashicorp/go-plugin" - "github.com/hashicorp/nomad/client/config" "github.com/hashicorp/nomad/client/driver/executor" dstructs "github.com/hashicorp/nomad/client/driver/structs" "github.com/hashicorp/nomad/client/fingerprint" @@ -155,7 +154,7 @@ func (d *QemuDriver) FSIsolation() cstructs.FSIsolation { return cstructs.FSIsolationImage } -func (d *QemuDriver) Fingerprint(cfg *config.Config, node *structs.Node) (bool, error) { +func (d *QemuDriver) Fingerprint(req *cstructs.FingerprintRequest, resp *cstructs.FingerprintResponse) error { bin := "qemu-system-x86_64" if runtime.GOOS == "windows" { // On windows, the "qemu-system-x86_64" command does not respond to the @@ -164,22 +163,24 @@ func (d *QemuDriver) Fingerprint(cfg *config.Config, node *structs.Node) (bool, } outBytes, err := exec.Command(bin, "--version").Output() if err != nil { - delete(node.Attributes, qemuDriverAttr) - return false, nil + // return no error, as it isn't an error to not find qemu, it just means we + // can't use it. + return nil } out := strings.TrimSpace(string(outBytes)) matches := reQemuVersion.FindStringSubmatch(out) if len(matches) != 2 { - delete(node.Attributes, qemuDriverAttr) - return false, fmt.Errorf("Unable to parse Qemu version string: %#v", matches) + resp.RemoveAttribute(qemuDriverAttr) + return fmt.Errorf("Unable to parse Qemu version string: %#v", matches) } currentQemuVersion := matches[1] - node.Attributes[qemuDriverAttr] = "1" - node.Attributes[qemuDriverVersionAttr] = currentQemuVersion + resp.AddAttribute(qemuDriverAttr, "1") + resp.AddAttribute(qemuDriverVersionAttr, currentQemuVersion) + resp.Detected = true - return true, nil + return nil } func (d *QemuDriver) Prestart(_ *ExecContext, task *structs.Task) (*PrestartResponse, error) { @@ -246,7 +247,7 @@ func (d *QemuDriver) Start(ctx *ExecContext, task *structs.Task) (*StartResponse } // This socket will be used to manage the virtual machine (for example, // to perform graceful shutdowns) - monitorPath, err := d.getMonitorPath(ctx.TaskDir.Dir) + monitorPath, err = d.getMonitorPath(ctx.TaskDir.Dir) if err != nil { d.logger.Printf("[ERR] driver.qemu: could not get qemu monitor path: %s", err) return nil, err @@ -464,6 +465,7 @@ func (h *qemuHandle) Kill() error { // If Nomad did not send a graceful shutdown signal, issue an interrupt to // the qemu process as a last resort if gracefulShutdownSent == false { + h.logger.Printf("[DEBUG] driver.qemu: graceful shutdown is not enabled, sending an interrupt signal to pid: %d", h.userPid) if err := h.executor.ShutDown(); err != nil { if h.pluginClient.Exited() { return nil diff --git a/client/driver/qemu_test.go b/client/driver/qemu_test.go index ebab5dae6..9dbfde76a 100644 --- a/client/driver/qemu_test.go +++ b/client/driver/qemu_test.go @@ -10,6 +10,7 @@ import ( "time" "github.com/hashicorp/nomad/client/config" + cstructs "github.com/hashicorp/nomad/client/structs" "github.com/hashicorp/nomad/nomad/structs" "github.com/hashicorp/nomad/testutil" @@ -34,17 +35,28 @@ func TestQemuDriver_Fingerprint(t *testing.T) { node := &structs.Node{ Attributes: make(map[string]string), } - apply, err := d.Fingerprint(&config.Config{}, node) + + request := &cstructs.FingerprintRequest{Config: &config.Config{}, Node: node} + var response cstructs.FingerprintResponse + err := d.Fingerprint(request, &response) if err != nil { t.Fatalf("err: %v", err) } - if !apply { - t.Fatalf("should apply") + + if !response.Detected { + t.Fatalf("expected response to be applicable") } - if node.Attributes[qemuDriverAttr] == "" { + + attributes := response.Attributes + if attributes == nil { + t.Fatalf("attributes should not be nil") + } + + if attributes[qemuDriverAttr] == "" { t.Fatalf("Missing Qemu driver") } - if node.Attributes[qemuDriverVersionAttr] == "" { + + if attributes[qemuDriverVersionAttr] == "" { t.Fatalf("Missing Qemu driver version") } } @@ -164,12 +176,15 @@ func TestQemuDriver_GracefulShutdown(t *testing.T) { defer ctx.AllocDir.Destroy() d := NewQemuDriver(ctx.DriverCtx) - apply, err := d.Fingerprint(&config.Config{}, ctx.DriverCtx.node) + request := &cstructs.FingerprintRequest{Config: &config.Config{}, Node: ctx.DriverCtx.node} + var response cstructs.FingerprintResponse + err := d.Fingerprint(request, &response) if err != nil { t.Fatalf("err: %v", err) } - if !apply { - t.Fatalf("should apply") + + for name, value := range response.Attributes { + ctx.DriverCtx.node.Attributes[name] = value } dst := ctx.ExecCtx.TaskDir.Dir diff --git a/client/driver/raw_exec.go b/client/driver/raw_exec.go index 86b4406e9..0cea2ea55 100644 --- a/client/driver/raw_exec.go +++ b/client/driver/raw_exec.go @@ -11,7 +11,6 @@ import ( "github.com/hashicorp/go-plugin" "github.com/hashicorp/nomad/client/allocdir" - "github.com/hashicorp/nomad/client/config" "github.com/hashicorp/nomad/client/driver/env" "github.com/hashicorp/nomad/client/driver/executor" dstructs "github.com/hashicorp/nomad/client/driver/structs" @@ -92,18 +91,19 @@ func (d *RawExecDriver) FSIsolation() cstructs.FSIsolation { return cstructs.FSIsolationNone } -func (d *RawExecDriver) Fingerprint(cfg *config.Config, node *structs.Node) (bool, error) { +func (d *RawExecDriver) Fingerprint(req *cstructs.FingerprintRequest, resp *cstructs.FingerprintResponse) error { // Check that the user has explicitly enabled this executor. - enabled := cfg.ReadBoolDefault(rawExecConfigOption, false) + enabled := req.Config.ReadBoolDefault(rawExecConfigOption, false) - if enabled || cfg.DevMode { + if enabled || req.Config.DevMode { d.logger.Printf("[WARN] driver.raw_exec: raw exec is enabled. Only enable if needed") - node.Attributes[rawExecDriverAttr] = "1" - return true, nil + resp.AddAttribute(rawExecDriverAttr, "1") + resp.Detected = true + return nil } - delete(node.Attributes, rawExecDriverAttr) - return false, nil + resp.RemoveAttribute(rawExecDriverAttr) + return nil } func (d *RawExecDriver) Prestart(*ExecContext, *structs.Task) (*PrestartResponse, error) { diff --git a/client/driver/raw_exec_test.go b/client/driver/raw_exec_test.go index dadaf0738..1cec71a29 100644 --- a/client/driver/raw_exec_test.go +++ b/client/driver/raw_exec_test.go @@ -12,6 +12,7 @@ import ( "github.com/hashicorp/nomad/client/config" "github.com/hashicorp/nomad/client/driver/env" + cstructs "github.com/hashicorp/nomad/client/structs" "github.com/hashicorp/nomad/helper/testtask" "github.com/hashicorp/nomad/nomad/structs" "github.com/hashicorp/nomad/testutil" @@ -34,27 +35,29 @@ func TestRawExecDriver_Fingerprint(t *testing.T) { // Disable raw exec. cfg := &config.Config{Options: map[string]string{rawExecConfigOption: "false"}} - apply, err := d.Fingerprint(cfg, node) + request := &cstructs.FingerprintRequest{Config: cfg, Node: node} + var response cstructs.FingerprintResponse + err := d.Fingerprint(request, &response) if err != nil { t.Fatalf("err: %v", err) } - if apply { - t.Fatalf("should not apply") - } - if node.Attributes["driver.raw_exec"] != "" { + + if response.Attributes["driver.raw_exec"] != "" { t.Fatalf("driver incorrectly enabled") } // Enable raw exec. - cfg.Options[rawExecConfigOption] = "true" - apply, err = d.Fingerprint(cfg, node) + request.Config.Options[rawExecConfigOption] = "true" + err = d.Fingerprint(request, &response) if err != nil { t.Fatalf("err: %v", err) } - if !apply { - t.Fatalf("should apply") + + if !response.Detected { + t.Fatalf("expected response to be applicable") } - if node.Attributes["driver.raw_exec"] != "1" { + + if response.Attributes["driver.raw_exec"] != "1" { t.Fatalf("driver not enabled") } } diff --git a/client/driver/rkt.go b/client/driver/rkt.go index 52c7c91f0..446a48eae 100644 --- a/client/driver/rkt.go +++ b/client/driver/rkt.go @@ -311,31 +311,30 @@ func (d *RktDriver) Abilities() DriverAbilities { } } -func (d *RktDriver) Fingerprint(cfg *config.Config, node *structs.Node) (bool, error) { +func (d *RktDriver) Fingerprint(req *cstructs.FingerprintRequest, resp *cstructs.FingerprintResponse) error { // Only enable if we are root when running on non-windows systems. if runtime.GOOS != "windows" && syscall.Geteuid() != 0 { if d.fingerprintSuccess == nil || *d.fingerprintSuccess { d.logger.Printf("[DEBUG] driver.rkt: must run as root user, disabling") } - delete(node.Attributes, rktDriverAttr) d.fingerprintSuccess = helper.BoolToPtr(false) - return false, nil + resp.RemoveAttribute(rktDriverAttr) + return nil } outBytes, err := exec.Command(rktCmd, "version").Output() if err != nil { - delete(node.Attributes, rktDriverAttr) d.fingerprintSuccess = helper.BoolToPtr(false) - return false, nil + return nil } out := strings.TrimSpace(string(outBytes)) rktMatches := reRktVersion.FindStringSubmatch(out) appcMatches := reAppcVersion.FindStringSubmatch(out) if len(rktMatches) != 2 || len(appcMatches) != 2 { - delete(node.Attributes, rktDriverAttr) d.fingerprintSuccess = helper.BoolToPtr(false) - return false, fmt.Errorf("Unable to parse Rkt version string: %#v", rktMatches) + resp.RemoveAttribute(rktDriverAttr) + return fmt.Errorf("Unable to parse Rkt version string: %#v", rktMatches) } minVersion, _ := version.NewVersion(minRktVersion) @@ -347,21 +346,22 @@ func (d *RktDriver) Fingerprint(cfg *config.Config, node *structs.Node) (bool, e d.logger.Printf("[WARN] driver.rkt: unsupported rkt version %s; please upgrade to >= %s", currentVersion, minVersion) } - delete(node.Attributes, rktDriverAttr) d.fingerprintSuccess = helper.BoolToPtr(false) - return false, nil + resp.RemoveAttribute(rktDriverAttr) + return nil } - node.Attributes[rktDriverAttr] = "1" - node.Attributes["driver.rkt.version"] = rktMatches[1] - node.Attributes["driver.rkt.appc.version"] = appcMatches[1] + resp.AddAttribute(rktDriverAttr, "1") + resp.AddAttribute("driver.rkt.version", rktMatches[1]) + resp.AddAttribute("driver.rkt.appc.version", appcMatches[1]) + resp.Detected = true // Advertise if this node supports rkt volumes if d.config.ReadBoolDefault(rktVolumesConfigOption, rktVolumesConfigDefault) { - node.Attributes["driver."+rktVolumesConfigOption] = "1" + resp.AddAttribute("driver."+rktVolumesConfigOption, "1") } d.fingerprintSuccess = helper.BoolToPtr(true) - return true, nil + return nil } func (d *RktDriver) Periodic() (bool, time.Duration) { diff --git a/client/driver/rkt_nonlinux.go b/client/driver/rkt_nonlinux.go index 49ae7268c..13c474def 100644 --- a/client/driver/rkt_nonlinux.go +++ b/client/driver/rkt_nonlinux.go @@ -5,7 +5,6 @@ package driver import ( "time" - "github.com/hashicorp/nomad/client/config" cstructs "github.com/hashicorp/nomad/client/structs" "github.com/hashicorp/nomad/nomad/structs" ) @@ -46,8 +45,8 @@ func (RktDriver) FSIsolation() cstructs.FSIsolation { panic("not implemented") } -func (RktDriver) Fingerprint(cfg *config.Config, node *structs.Node) (bool, error) { - return false, nil +func (RktDriver) Fingerprint(req *cstructs.FingerprintRequest, resp *cstructs.FingerprintResponse) error { + return nil } func (RktDriver) Periodic() (bool, time.Duration) { diff --git a/client/driver/rkt_test.go b/client/driver/rkt_test.go index 4fd5057d3..604090dc0 100644 --- a/client/driver/rkt_test.go +++ b/client/driver/rkt_test.go @@ -17,6 +17,7 @@ import ( "time" "github.com/hashicorp/nomad/client/config" + cstructs "github.com/hashicorp/nomad/client/structs" "github.com/hashicorp/nomad/nomad/structs" "github.com/hashicorp/nomad/testutil" "github.com/stretchr/testify/assert" @@ -57,20 +58,29 @@ func TestRktDriver_Fingerprint(t *testing.T) { node := &structs.Node{ Attributes: make(map[string]string), } - apply, err := d.Fingerprint(&config.Config{}, node) + + request := &cstructs.FingerprintRequest{Config: &config.Config{}, Node: node} + var response cstructs.FingerprintResponse + err := d.Fingerprint(request, &response) if err != nil { t.Fatalf("err: %v", err) } - if !apply { - t.Fatalf("should apply") + + if !response.Detected { + t.Fatalf("expected response to be applicable") } - if node.Attributes["driver.rkt"] != "1" { + + attributes := response.Attributes + if attributes == nil { + t.Fatalf("expected attributes to not equal nil") + } + if attributes["driver.rkt"] != "1" { t.Fatalf("Missing Rkt driver") } - if node.Attributes["driver.rkt.version"] == "" { + if attributes["driver.rkt.version"] == "" { t.Fatalf("Missing Rkt driver version") } - if node.Attributes["driver.rkt.appc.version"] == "" { + if attributes["driver.rkt.appc.version"] == "" { t.Fatalf("Missing appc version for the Rkt driver") } } diff --git a/client/fingerprint/arch.go b/client/fingerprint/arch.go index 71b5352a8..3277822bc 100644 --- a/client/fingerprint/arch.go +++ b/client/fingerprint/arch.go @@ -4,8 +4,7 @@ import ( "log" "runtime" - client "github.com/hashicorp/nomad/client/config" - "github.com/hashicorp/nomad/nomad/structs" + cstructs "github.com/hashicorp/nomad/client/structs" ) // ArchFingerprint is used to fingerprint the architecture @@ -20,7 +19,8 @@ func NewArchFingerprint(logger *log.Logger) Fingerprint { return f } -func (f *ArchFingerprint) Fingerprint(config *client.Config, node *structs.Node) (bool, error) { - node.Attributes["cpu.arch"] = runtime.GOARCH - return true, nil +func (f *ArchFingerprint) Fingerprint(req *cstructs.FingerprintRequest, resp *cstructs.FingerprintResponse) error { + resp.AddAttribute("cpu.arch", runtime.GOARCH) + resp.Detected = true + return nil } diff --git a/client/fingerprint/arch_test.go b/client/fingerprint/arch_test.go index 4e4b94a67..320ccc321 100644 --- a/client/fingerprint/arch_test.go +++ b/client/fingerprint/arch_test.go @@ -4,6 +4,7 @@ import ( "testing" "github.com/hashicorp/nomad/client/config" + cstructs "github.com/hashicorp/nomad/client/structs" "github.com/hashicorp/nomad/nomad/structs" ) @@ -12,14 +13,17 @@ func TestArchFingerprint(t *testing.T) { node := &structs.Node{ Attributes: make(map[string]string), } - ok, err := f.Fingerprint(&config.Config{}, node) + + request := &cstructs.FingerprintRequest{Config: &config.Config{}, Node: node} + var response cstructs.FingerprintResponse + err := f.Fingerprint(request, &response) if err != nil { t.Fatalf("err: %v", err) } - if !ok { - t.Fatalf("should apply") - } - if node.Attributes["cpu.arch"] == "" { - t.Fatalf("missing arch") + + if !response.Detected { + t.Fatalf("expected response to be applicable") } + + assertNodeAttributeContains(t, response.Attributes, "cpu.arch") } diff --git a/client/fingerprint/cgroup.go b/client/fingerprint/cgroup.go index 1ec8d8793..2e6c44637 100644 --- a/client/fingerprint/cgroup.go +++ b/client/fingerprint/cgroup.go @@ -6,7 +6,7 @@ import ( "log" "time" - "github.com/hashicorp/nomad/nomad/structs" + cstructs "github.com/hashicorp/nomad/client/structs" ) const ( @@ -49,8 +49,8 @@ func NewCGroupFingerprint(logger *log.Logger) Fingerprint { // clearCGroupAttributes clears any node attributes related to cgroups that might // have been set in a previous fingerprint run. -func (f *CGroupFingerprint) clearCGroupAttributes(n *structs.Node) { - delete(n.Attributes, "unique.cgroup.mountpoint") +func (f *CGroupFingerprint) clearCGroupAttributes(r *cstructs.FingerprintResponse) { + r.RemoveAttribute("unique.cgroup.mountpoint") } // Periodic determines the interval at which the periodic fingerprinter will run. diff --git a/client/fingerprint/cgroup_linux.go b/client/fingerprint/cgroup_linux.go index 9abb959b5..c6f78cd35 100644 --- a/client/fingerprint/cgroup_linux.go +++ b/client/fingerprint/cgroup_linux.go @@ -5,8 +5,7 @@ package fingerprint import ( "fmt" - client "github.com/hashicorp/nomad/client/config" - "github.com/hashicorp/nomad/nomad/structs" + cstructs "github.com/hashicorp/nomad/client/structs" "github.com/opencontainers/runc/libcontainer/cgroups" ) @@ -28,30 +27,31 @@ func FindCgroupMountpointDir() (string, error) { } // Fingerprint tries to find a valid cgroup moint point -func (f *CGroupFingerprint) Fingerprint(cfg *client.Config, node *structs.Node) (bool, error) { +func (f *CGroupFingerprint) Fingerprint(req *cstructs.FingerprintRequest, resp *cstructs.FingerprintResponse) error { mount, err := f.mountPointDetector.MountPoint() if err != nil { - f.clearCGroupAttributes(node) - return false, fmt.Errorf("Failed to discover cgroup mount point: %s", err) + f.clearCGroupAttributes(resp) + return fmt.Errorf("Failed to discover cgroup mount point: %s", err) } // Check if a cgroup mount point was found if mount == "" { - // Clear any attributes from the previous fingerprint. - f.clearCGroupAttributes(node) + + f.clearCGroupAttributes(resp) if f.lastState == cgroupAvailable { f.logger.Printf("[INFO] fingerprint.cgroups: cgroups are unavailable") } f.lastState = cgroupUnavailable - return true, nil + return nil } - node.Attributes["unique.cgroup.mountpoint"] = mount + resp.AddAttribute("unique.cgroup.mountpoint", mount) + resp.Detected = true if f.lastState == cgroupUnavailable { f.logger.Printf("[INFO] fingerprint.cgroups: cgroups are available") } f.lastState = cgroupAvailable - return true, nil + return nil } diff --git a/client/fingerprint/cgroup_test.go b/client/fingerprint/cgroup_test.go index f12379405..2dc1d51ec 100644 --- a/client/fingerprint/cgroup_test.go +++ b/client/fingerprint/cgroup_test.go @@ -7,6 +7,7 @@ import ( "testing" "github.com/hashicorp/nomad/client/config" + cstructs "github.com/hashicorp/nomad/client/structs" "github.com/hashicorp/nomad/nomad/structs" ) @@ -39,64 +40,91 @@ func (m *MountPointDetectorEmptyMountPoint) MountPoint() (string, error) { } func TestCGroupFingerprint(t *testing.T) { - f := &CGroupFingerprint{ - logger: testLogger(), - lastState: cgroupUnavailable, - mountPointDetector: &MountPointDetectorMountPointFail{}, + { + f := &CGroupFingerprint{ + logger: testLogger(), + lastState: cgroupUnavailable, + mountPointDetector: &MountPointDetectorMountPointFail{}, + } + + node := &structs.Node{ + Attributes: make(map[string]string), + } + + request := &cstructs.FingerprintRequest{Config: &config.Config{}, Node: node} + var response cstructs.FingerprintResponse + err := f.Fingerprint(request, &response) + if err == nil { + t.Fatalf("expected an error") + } + + if a, _ := response.Attributes["unique.cgroup.mountpoint"]; a != "" { + t.Fatalf("unexpected attribute found, %s", a) + } } - node := &structs.Node{ - Attributes: make(map[string]string), + { + f := &CGroupFingerprint{ + logger: testLogger(), + lastState: cgroupUnavailable, + mountPointDetector: &MountPointDetectorValidMountPoint{}, + } + + node := &structs.Node{ + Attributes: make(map[string]string), + } + + request := &cstructs.FingerprintRequest{Config: &config.Config{}, Node: node} + var response cstructs.FingerprintResponse + err := f.Fingerprint(request, &response) + if err != nil { + t.Fatalf("unexpected error, %s", err) + } + if a, ok := response.Attributes["unique.cgroup.mountpoint"]; !ok { + t.Fatalf("unable to find attribute: %s", a) + } } - ok, err := f.Fingerprint(&config.Config{}, node) - if err == nil { - t.Fatalf("expected an error") - } - if ok { - t.Fatalf("should not apply") - } - if a, ok := node.Attributes["unique.cgroup.mountpoint"]; ok { - t.Fatalf("unexpected attribute found, %s", a) - } + { + f := &CGroupFingerprint{ + logger: testLogger(), + lastState: cgroupUnavailable, + mountPointDetector: &MountPointDetectorEmptyMountPoint{}, + } - f = &CGroupFingerprint{ - logger: testLogger(), - lastState: cgroupUnavailable, - mountPointDetector: &MountPointDetectorValidMountPoint{}, - } + node := &structs.Node{ + Attributes: make(map[string]string), + } - node = &structs.Node{ - Attributes: make(map[string]string), + request := &cstructs.FingerprintRequest{Config: &config.Config{}, Node: node} + var response cstructs.FingerprintResponse + err := f.Fingerprint(request, &response) + if err != nil { + t.Fatalf("unexpected error, %s", err) + } + if a, _ := response.Attributes["unique.cgroup.mountpoint"]; a != "" { + t.Fatalf("unexpected attribute found, %s", a) + } } + { + f := &CGroupFingerprint{ + logger: testLogger(), + lastState: cgroupAvailable, + mountPointDetector: &MountPointDetectorValidMountPoint{}, + } - ok, err = f.Fingerprint(&config.Config{}, node) - if err != nil { - t.Fatalf("unexpected error, %s", err) - } - if !ok { - t.Fatalf("should apply") - } - assertNodeAttributeContains(t, node, "unique.cgroup.mountpoint") + node := &structs.Node{ + Attributes: make(map[string]string), + } - f = &CGroupFingerprint{ - logger: testLogger(), - lastState: cgroupUnavailable, - mountPointDetector: &MountPointDetectorEmptyMountPoint{}, - } - - node = &structs.Node{ - Attributes: make(map[string]string), - } - - ok, err = f.Fingerprint(&config.Config{}, node) - if err != nil { - t.Fatalf("unexpected error, %s", err) - } - if !ok { - t.Fatalf("should apply") - } - if a, ok := node.Attributes["unique.cgroup.mountpoint"]; ok { - t.Fatalf("unexpected attribute found, %s", a) + request := &cstructs.FingerprintRequest{Config: &config.Config{}, Node: node} + var response cstructs.FingerprintResponse + err := f.Fingerprint(request, &response) + if err != nil { + t.Fatalf("unexpected error, %s", err) + } + if a, _ := response.Attributes["unique.cgroup.mountpoint"]; a == "" { + t.Fatalf("expected attribute to be found, %s", a) + } } } diff --git a/client/fingerprint/consul.go b/client/fingerprint/consul.go index b3790a417..84c6a97d6 100644 --- a/client/fingerprint/consul.go +++ b/client/fingerprint/consul.go @@ -8,8 +8,7 @@ import ( consul "github.com/hashicorp/consul/api" - client "github.com/hashicorp/nomad/client/config" - "github.com/hashicorp/nomad/nomad/structs" + cstructs "github.com/hashicorp/nomad/client/structs" ) const ( @@ -29,23 +28,18 @@ func NewConsulFingerprint(logger *log.Logger) Fingerprint { return &ConsulFingerprint{logger: logger, lastState: consulUnavailable} } -func (f *ConsulFingerprint) Fingerprint(config *client.Config, node *structs.Node) (bool, error) { - // Guard against uninitialized Links - if node.Links == nil { - node.Links = map[string]string{} - } - +func (f *ConsulFingerprint) Fingerprint(req *cstructs.FingerprintRequest, resp *cstructs.FingerprintResponse) error { // Only create the client once to avoid creating too many connections to // Consul. if f.client == nil { - consulConfig, err := config.ConsulConfig.ApiConfig() + consulConfig, err := req.Config.ConsulConfig.ApiConfig() if err != nil { - return false, fmt.Errorf("Failed to initialize the Consul client config: %v", err) + return fmt.Errorf("Failed to initialize the Consul client config: %v", err) } f.client, err = consul.NewClient(consulConfig) if err != nil { - return false, fmt.Errorf("Failed to initialize consul client: %s", err) + return fmt.Errorf("Failed to initialize consul client: %s", err) } } @@ -53,8 +47,7 @@ func (f *ConsulFingerprint) Fingerprint(config *client.Config, node *structs.Nod // If we can't hit this URL consul is probably not running on this machine. info, err := f.client.Agent().Self() if err != nil { - // Clear any attributes set by a previous fingerprint. - f.clearConsulAttributes(node) + f.clearConsulAttributes(resp) // Print a message indicating that the Consul Agent is not available // anymore @@ -62,39 +55,39 @@ func (f *ConsulFingerprint) Fingerprint(config *client.Config, node *structs.Nod f.logger.Printf("[INFO] fingerprint.consul: consul agent is unavailable") } f.lastState = consulUnavailable - return false, nil + return nil } if s, ok := info["Config"]["Server"].(bool); ok { - node.Attributes["consul.server"] = strconv.FormatBool(s) + resp.AddAttribute("consul.server", strconv.FormatBool(s)) } else { f.logger.Printf("[WARN] fingerprint.consul: unable to fingerprint consul.server") } if v, ok := info["Config"]["Version"].(string); ok { - node.Attributes["consul.version"] = v + resp.AddAttribute("consul.version", v) } else { f.logger.Printf("[WARN] fingerprint.consul: unable to fingerprint consul.version") } if r, ok := info["Config"]["Revision"].(string); ok { - node.Attributes["consul.revision"] = r + resp.AddAttribute("consul.revision", r) } else { f.logger.Printf("[WARN] fingerprint.consul: unable to fingerprint consul.revision") } if n, ok := info["Config"]["NodeName"].(string); ok { - node.Attributes["unique.consul.name"] = n + resp.AddAttribute("unique.consul.name", n) } else { f.logger.Printf("[WARN] fingerprint.consul: unable to fingerprint unique.consul.name") } if d, ok := info["Config"]["Datacenter"].(string); ok { - node.Attributes["consul.datacenter"] = d + resp.AddAttribute("consul.datacenter", d) } else { f.logger.Printf("[WARN] fingerprint.consul: unable to fingerprint consul.datacenter") } - if node.Attributes["consul.datacenter"] != "" || node.Attributes["unique.consul.name"] != "" { - node.Links["consul"] = fmt.Sprintf("%s.%s", - node.Attributes["consul.datacenter"], - node.Attributes["unique.consul.name"]) + if dc, ok := resp.Attributes["consul.datacenter"]; ok { + if name, ok2 := resp.Attributes["unique.consul.name"]; ok2 { + resp.AddLink("consul", fmt.Sprintf("%s.%s", dc, name)) + } } else { f.logger.Printf("[WARN] fingerprint.consul: malformed Consul response prevented linking") } @@ -105,18 +98,19 @@ func (f *ConsulFingerprint) Fingerprint(config *client.Config, node *structs.Nod f.logger.Printf("[INFO] fingerprint.consul: consul agent is available") } f.lastState = consulAvailable - return true, nil + resp.Detected = true + return nil } // clearConsulAttributes removes consul attributes and links from the passed // Node. -func (f *ConsulFingerprint) clearConsulAttributes(n *structs.Node) { - delete(n.Attributes, "consul.server") - delete(n.Attributes, "consul.version") - delete(n.Attributes, "consul.revision") - delete(n.Attributes, "unique.consul.name") - delete(n.Attributes, "consul.datacenter") - delete(n.Links, "consul") +func (f *ConsulFingerprint) clearConsulAttributes(r *cstructs.FingerprintResponse) { + r.RemoveAttribute("consul.server") + r.RemoveAttribute("consul.version") + r.RemoveAttribute("consul.revision") + r.RemoveAttribute("unique.consul.name") + r.RemoveAttribute("consul.datacenter") + r.RemoveLink("consul") } func (f *ConsulFingerprint) Periodic() (bool, time.Duration) { diff --git a/client/fingerprint/consul_test.go b/client/fingerprint/consul_test.go index e2eecf438..400ab01f4 100644 --- a/client/fingerprint/consul_test.go +++ b/client/fingerprint/consul_test.go @@ -8,6 +8,7 @@ import ( "testing" "github.com/hashicorp/nomad/client/config" + cstructs "github.com/hashicorp/nomad/client/structs" "github.com/hashicorp/nomad/nomad/structs" "github.com/stretchr/testify/assert" ) @@ -24,24 +25,27 @@ func TestConsulFingerprint(t *testing.T) { })) defer ts.Close() - config := config.DefaultConfig() - config.ConsulConfig.Addr = strings.TrimPrefix(ts.URL, "http://") + conf := config.DefaultConfig() + conf.ConsulConfig.Addr = strings.TrimPrefix(ts.URL, "http://") - ok, err := fp.Fingerprint(config, node) + request := &cstructs.FingerprintRequest{Config: conf, Node: node} + var response cstructs.FingerprintResponse + err := fp.Fingerprint(request, &response) if err != nil { t.Fatalf("Failed to fingerprint: %s", err) } - if !ok { - t.Fatalf("Failed to apply node attributes") + + if !response.Detected { + t.Fatalf("expected response to be applicable") } - assertNodeAttributeContains(t, node, "consul.server") - assertNodeAttributeContains(t, node, "consul.version") - assertNodeAttributeContains(t, node, "consul.revision") - assertNodeAttributeContains(t, node, "unique.consul.name") - assertNodeAttributeContains(t, node, "consul.datacenter") + assertNodeAttributeContains(t, response.Attributes, "consul.server") + assertNodeAttributeContains(t, response.Attributes, "consul.version") + assertNodeAttributeContains(t, response.Attributes, "consul.revision") + assertNodeAttributeContains(t, response.Attributes, "unique.consul.name") + assertNodeAttributeContains(t, response.Attributes, "consul.datacenter") - if _, ok := node.Links["consul"]; !ok { + if _, ok := response.Links["consul"]; !ok { t.Errorf("Expected a link to consul, none found") } } @@ -177,12 +181,17 @@ func TestConsulFingerprint_UnexpectedResponse(t *testing.T) { })) defer ts.Close() - config := config.DefaultConfig() - config.ConsulConfig.Addr = strings.TrimPrefix(ts.URL, "http://") + conf := config.DefaultConfig() + conf.ConsulConfig.Addr = strings.TrimPrefix(ts.URL, "http://") - ok, err := fp.Fingerprint(config, node) + request := &cstructs.FingerprintRequest{Config: conf, Node: node} + var response cstructs.FingerprintResponse + err := fp.Fingerprint(request, &response) assert.Nil(err) - assert.True(ok) + + if !response.Detected { + t.Fatalf("expected response to be applicable") + } attrs := []string{ "consul.server", @@ -191,13 +200,14 @@ func TestConsulFingerprint_UnexpectedResponse(t *testing.T) { "unique.consul.name", "consul.datacenter", } + for _, attr := range attrs { - if v, ok := node.Attributes[attr]; ok { + if v, ok := response.Attributes[attr]; ok { t.Errorf("unexpected node attribute %q with vlaue %q", attr, v) } } - if v, ok := node.Links["consul"]; ok { + if v, ok := response.Links["consul"]; ok { t.Errorf("Unexpected link to consul: %v", v) } } diff --git a/client/fingerprint/cpu.go b/client/fingerprint/cpu.go index 0c9b4c25d..434eb5844 100644 --- a/client/fingerprint/cpu.go +++ b/client/fingerprint/cpu.go @@ -4,7 +4,7 @@ import ( "fmt" "log" - "github.com/hashicorp/nomad/client/config" + cstructs "github.com/hashicorp/nomad/client/structs" "github.com/hashicorp/nomad/helper/stats" "github.com/hashicorp/nomad/nomad/structs" ) @@ -21,13 +21,12 @@ func NewCPUFingerprint(logger *log.Logger) Fingerprint { return f } -func (f *CPUFingerprint) Fingerprint(cfg *config.Config, node *structs.Node) (bool, error) { - setResources := func(totalCompute int) { - if node.Resources == nil { - node.Resources = &structs.Resources{} +func (f *CPUFingerprint) Fingerprint(req *cstructs.FingerprintRequest, resp *cstructs.FingerprintResponse) error { + cfg := req.Config + setResourcesCPU := func(totalCompute int) { + resp.Resources = &structs.Resources{ + CPU: totalCompute, } - - node.Resources.CPU = totalCompute } if err := stats.Init(); err != nil { @@ -35,21 +34,21 @@ func (f *CPUFingerprint) Fingerprint(cfg *config.Config, node *structs.Node) (bo } if cfg.CpuCompute != 0 { - setResources(cfg.CpuCompute) - return true, nil + setResourcesCPU(cfg.CpuCompute) + return nil } if modelName := stats.CPUModelName(); modelName != "" { - node.Attributes["cpu.modelname"] = modelName + resp.AddAttribute("cpu.modelname", modelName) } if mhz := stats.CPUMHzPerCore(); mhz > 0 { - node.Attributes["cpu.frequency"] = fmt.Sprintf("%.0f", mhz) + resp.AddAttribute("cpu.frequency", fmt.Sprintf("%.0f", mhz)) f.logger.Printf("[DEBUG] fingerprint.cpu: frequency: %.0f MHz", mhz) } if numCores := stats.CPUNumCores(); numCores > 0 { - node.Attributes["cpu.numcores"] = fmt.Sprintf("%d", numCores) + resp.AddAttribute("cpu.numcores", fmt.Sprintf("%d", numCores)) f.logger.Printf("[DEBUG] fingerprint.cpu: core count: %d", numCores) } @@ -62,17 +61,14 @@ func (f *CPUFingerprint) Fingerprint(cfg *config.Config, node *structs.Node) (bo // Return an error if no cpu was detected or explicitly set as this // node would be unable to receive any allocations. if tt == 0 { - return false, fmt.Errorf("cannot detect cpu total compute. "+ + return fmt.Errorf("cannot detect cpu total compute. "+ "CPU compute must be set manually using the client config option %q", "cpu_total_compute") } - node.Attributes["cpu.totalcompute"] = fmt.Sprintf("%d", tt) + resp.AddAttribute("cpu.totalcompute", fmt.Sprintf("%d", tt)) + setResourcesCPU(tt) + resp.Detected = true - if node.Resources == nil { - node.Resources = &structs.Resources{} - } - - node.Resources.CPU = tt - return true, nil + return nil } diff --git a/client/fingerprint/cpu_test.go b/client/fingerprint/cpu_test.go index 238d55770..5bb761970 100644 --- a/client/fingerprint/cpu_test.go +++ b/client/fingerprint/cpu_test.go @@ -4,6 +4,7 @@ import ( "testing" "github.com/hashicorp/nomad/client/config" + cstructs "github.com/hashicorp/nomad/client/structs" "github.com/hashicorp/nomad/nomad/structs" ) @@ -12,33 +13,40 @@ func TestCPUFingerprint(t *testing.T) { node := &structs.Node{ Attributes: make(map[string]string), } - ok, err := f.Fingerprint(&config.Config{}, node) + + request := &cstructs.FingerprintRequest{Config: &config.Config{}, Node: node} + var response cstructs.FingerprintResponse + err := f.Fingerprint(request, &response) if err != nil { t.Fatalf("err: %v", err) } - if !ok { - t.Fatalf("should apply") + + if !response.Detected { + t.Fatalf("expected response to be applicable") } // CPU info - if node.Attributes["cpu.numcores"] == "" { + attributes := response.Attributes + if attributes == nil { + t.Fatalf("expected attributes to be initialized") + } + if attributes["cpu.numcores"] == "" { t.Fatalf("Missing Num Cores") } - if node.Attributes["cpu.modelname"] == "" { + if attributes["cpu.modelname"] == "" { t.Fatalf("Missing Model Name") } - if node.Attributes["cpu.frequency"] == "" { + if attributes["cpu.frequency"] == "" { t.Fatalf("Missing CPU Frequency") } - if node.Attributes["cpu.totalcompute"] == "" { + if attributes["cpu.totalcompute"] == "" { t.Fatalf("Missing CPU Total Compute") } - if node.Resources == nil || node.Resources.CPU == 0 { + if response.Resources == nil || response.Resources.CPU == 0 { t.Fatalf("Expected to find CPU Resources") } - } // TestCPUFingerprint_OverrideCompute asserts that setting cpu_total_compute in @@ -49,30 +57,41 @@ func TestCPUFingerprint_OverrideCompute(t *testing.T) { Attributes: make(map[string]string), } cfg := &config.Config{} - ok, err := f.Fingerprint(cfg, node) - if err != nil { - t.Fatalf("err: %v", err) - } - if !ok { - t.Fatalf("should apply") + var originalCPU int + + { + request := &cstructs.FingerprintRequest{Config: cfg, Node: node} + var response cstructs.FingerprintResponse + err := f.Fingerprint(request, &response) + if err != nil { + t.Fatalf("err: %v", err) + } + + if !response.Detected { + t.Fatalf("expected response to be applicable") + } + + if response.Resources.CPU == 0 { + t.Fatalf("expected fingerprint of cpu of but found 0") + } + + originalCPU = response.Resources.CPU } - // Get actual system CPU - origCPU := node.Resources.CPU + { + // Override it with a setting + cfg.CpuCompute = originalCPU + 123 - // Override it with a setting - cfg.CpuCompute = origCPU + 123 + // Make sure the Fingerprinter applies the override to the node resources + request := &cstructs.FingerprintRequest{Config: cfg, Node: node} + var response cstructs.FingerprintResponse + err := f.Fingerprint(request, &response) + if err != nil { + t.Fatalf("err: %v", err) + } - // Make sure the Fingerprinter applies the override - ok, err = f.Fingerprint(cfg, node) - if err != nil { - t.Fatalf("err: %v", err) - } - if !ok { - t.Fatalf("should apply") - } - - if node.Resources.CPU != cfg.CpuCompute { - t.Fatalf("expected override cpu of %d but found %d", cfg.CpuCompute, node.Resources.CPU) + if response.Resources.CPU != cfg.CpuCompute { + t.Fatalf("expected override cpu of %d but found %d", cfg.CpuCompute, response.Resources.CPU) + } } } diff --git a/client/fingerprint/env_aws.go b/client/fingerprint/env_aws.go index b442f49ff..79bd6730f 100644 --- a/client/fingerprint/env_aws.go +++ b/client/fingerprint/env_aws.go @@ -12,7 +12,7 @@ import ( "time" "github.com/hashicorp/go-cleanhttp" - "github.com/hashicorp/nomad/client/config" + cstructs "github.com/hashicorp/nomad/client/structs" "github.com/hashicorp/nomad/nomad/structs" ) @@ -63,14 +63,16 @@ func NewEnvAWSFingerprint(logger *log.Logger) Fingerprint { return f } -func (f *EnvAWSFingerprint) Fingerprint(cfg *config.Config, node *structs.Node) (bool, error) { +func (f *EnvAWSFingerprint) Fingerprint(request *cstructs.FingerprintRequest, response *cstructs.FingerprintResponse) error { + cfg := request.Config + // Check if we should tighten the timeout if cfg.ReadBoolDefault(TightenNetworkTimeoutsConfig, false) { f.timeout = 1 * time.Millisecond } if !f.isAWS() { - return false, nil + return nil } // newNetwork is populated and addded to the Nodes resources @@ -78,9 +80,6 @@ func (f *EnvAWSFingerprint) Fingerprint(cfg *config.Config, node *structs.Node) Device: "eth0", } - if node.Links == nil { - node.Links = make(map[string]string) - } metadataURL := os.Getenv("AWS_ENV_URL") if metadataURL == "" { metadataURL = DEFAULT_AWS_URL @@ -115,10 +114,10 @@ func (f *EnvAWSFingerprint) Fingerprint(cfg *config.Config, node *structs.Node) // if it's a URL error, assume we're not in an AWS environment // TODO: better way to detect AWS? Check xen virtualization? if _, ok := err.(*url.Error); ok { - return false, nil + return nil } // not sure what other errors it would return - return false, err + return err } resp, err := ioutil.ReadAll(res.Body) res.Body.Close() @@ -132,12 +131,12 @@ func (f *EnvAWSFingerprint) Fingerprint(cfg *config.Config, node *structs.Node) key = structs.UniqueNamespace(key) } - node.Attributes[key] = strings.Trim(string(resp), "\n") + response.AddAttribute(key, strings.Trim(string(resp), "\n")) } // copy over network specific information - if val := node.Attributes["unique.platform.aws.local-ipv4"]; val != "" { - node.Attributes["unique.network.ip-address"] = val + if val, ok := response.Attributes["unique.platform.aws.local-ipv4"]; ok && val != "" { + response.AddAttribute("unique.network.ip-address", val) newNetwork.IP = val newNetwork.CIDR = newNetwork.IP + "/32" } @@ -149,8 +148,8 @@ func (f *EnvAWSFingerprint) Fingerprint(cfg *config.Config, node *structs.Node) } else if throughput == 0 { // Failed to determine speed. Check if the network fingerprint got it found := false - if node.Resources != nil && len(node.Resources.Networks) > 0 { - for _, n := range node.Resources.Networks { + if request.Node.Resources != nil && len(request.Node.Resources.Networks) > 0 { + for _, n := range request.Node.Resources.Networks { if n.IP == newNetwork.IP { throughput = n.MBits found = true @@ -165,19 +164,18 @@ func (f *EnvAWSFingerprint) Fingerprint(cfg *config.Config, node *structs.Node) } } - // populate Node Network Resources - if node.Resources == nil { - node.Resources = &structs.Resources{} - } newNetwork.MBits = throughput - node.Resources.Networks = []*structs.NetworkResource{newNetwork} + response.Resources = &structs.Resources{ + Networks: []*structs.NetworkResource{newNetwork}, + } // populate Links - node.Links["aws.ec2"] = fmt.Sprintf("%s.%s", - node.Attributes["platform.aws.placement.availability-zone"], - node.Attributes["unique.platform.aws.instance-id"]) + response.AddLink("aws.ec2", fmt.Sprintf("%s.%s", + response.Attributes["platform.aws.placement.availability-zone"], + response.Attributes["unique.platform.aws.instance-id"])) + response.Detected = true - return true, nil + return nil } func (f *EnvAWSFingerprint) isAWS() bool { diff --git a/client/fingerprint/env_aws_test.go b/client/fingerprint/env_aws_test.go index 77f7c1dc9..2a94561df 100644 --- a/client/fingerprint/env_aws_test.go +++ b/client/fingerprint/env_aws_test.go @@ -9,6 +9,7 @@ import ( "testing" "github.com/hashicorp/nomad/client/config" + cstructs "github.com/hashicorp/nomad/client/structs" "github.com/hashicorp/nomad/nomad/structs" ) @@ -19,13 +20,15 @@ func TestEnvAWSFingerprint_nonAws(t *testing.T) { Attributes: make(map[string]string), } - ok, err := f.Fingerprint(&config.Config{}, node) + request := &cstructs.FingerprintRequest{Config: &config.Config{}, Node: node} + var response cstructs.FingerprintResponse + err := f.Fingerprint(request, &response) if err != nil { t.Fatalf("err: %v", err) } - if ok { - t.Fatalf("Should be false without test server") + if len(response.Attributes) > 0 { + t.Fatalf("Should not apply") } } @@ -51,15 +54,13 @@ func TestEnvAWSFingerprint_aws(t *testing.T) { defer ts.Close() os.Setenv("AWS_ENV_URL", ts.URL+"/latest/meta-data/") - ok, err := f.Fingerprint(&config.Config{}, node) + request := &cstructs.FingerprintRequest{Config: &config.Config{}, Node: node} + var response cstructs.FingerprintResponse + err := f.Fingerprint(request, &response) if err != nil { t.Fatalf("err: %v", err) } - if !ok { - t.Fatalf("Expected AWS attributes and Links") - } - keys := []string{ "platform.aws.ami-id", "unique.platform.aws.hostname", @@ -74,16 +75,16 @@ func TestEnvAWSFingerprint_aws(t *testing.T) { } for _, k := range keys { - assertNodeAttributeContains(t, node, k) + assertNodeAttributeContains(t, response.Attributes, k) } - if len(node.Links) == 0 { + if len(response.Links) == 0 { t.Fatalf("Empty links for Node in AWS Fingerprint test") } // confirm we have at least instance-id and ami-id for _, k := range []string{"aws.ec2"} { - assertNodeLinksContains(t, node, k) + assertNodeLinksContains(t, response.Links, k) } } @@ -171,22 +172,21 @@ func TestNetworkFingerprint_AWS(t *testing.T) { Attributes: make(map[string]string), } - ok, err := f.Fingerprint(&config.Config{}, node) + request := &cstructs.FingerprintRequest{Config: &config.Config{}, Node: node} + var response cstructs.FingerprintResponse + err := f.Fingerprint(request, &response) if err != nil { t.Fatalf("err: %v", err) } - if !ok { - t.Fatalf("should apply") - } - assertNodeAttributeContains(t, node, "unique.network.ip-address") + assertNodeAttributeContains(t, response.Attributes, "unique.network.ip-address") - if node.Resources == nil || len(node.Resources.Networks) == 0 { + if response.Resources == nil || len(response.Resources.Networks) == 0 { t.Fatal("Expected to find Network Resources") } // Test at least the first Network Resource - net := node.Resources.Networks[0] + net := response.Resources.Networks[0] if net.IP == "" { t.Fatal("Expected Network Resource to have an IP") } @@ -217,73 +217,81 @@ func TestNetworkFingerprint_AWS_network(t *testing.T) { os.Setenv("AWS_ENV_URL", ts.URL+"/latest/meta-data/") f := NewEnvAWSFingerprint(testLogger()) - node := &structs.Node{ - Attributes: make(map[string]string), - } + { + node := &structs.Node{ + Attributes: make(map[string]string), + } - cfg := &config.Config{} - ok, err := f.Fingerprint(cfg, node) - if err != nil { - t.Fatalf("err: %v", err) - } - if !ok { - t.Fatalf("should apply") - } + request := &cstructs.FingerprintRequest{Config: &config.Config{}, Node: node} + var response cstructs.FingerprintResponse + err := f.Fingerprint(request, &response) + if err != nil { + t.Fatalf("err: %v", err) + } - assertNodeAttributeContains(t, node, "unique.network.ip-address") + if !response.Detected { + t.Fatalf("expected response to be applicable") + } - if node.Resources == nil || len(node.Resources.Networks) == 0 { - t.Fatal("Expected to find Network Resources") - } + assertNodeAttributeContains(t, response.Attributes, "unique.network.ip-address") - // Test at least the first Network Resource - net := node.Resources.Networks[0] - if net.IP == "" { - t.Fatal("Expected Network Resource to have an IP") - } - if net.CIDR == "" { - t.Fatal("Expected Network Resource to have a CIDR") - } - if net.Device == "" { - t.Fatal("Expected Network Resource to have a Device Name") - } - if net.MBits != 1000 { - t.Fatalf("Expected Network Resource to have speed %d; got %d", 1000, net.MBits) + if response.Resources == nil || len(response.Resources.Networks) == 0 { + t.Fatal("Expected to find Network Resources") + } + + // Test at least the first Network Resource + net := response.Resources.Networks[0] + if net.IP == "" { + t.Fatal("Expected Network Resource to have an IP") + } + if net.CIDR == "" { + t.Fatal("Expected Network Resource to have a CIDR") + } + if net.Device == "" { + t.Fatal("Expected Network Resource to have a Device Name") + } + if net.MBits != 1000 { + t.Fatalf("Expected Network Resource to have speed %d; got %d", 1000, net.MBits) + } } // Try again this time setting a network speed in the config - node = &structs.Node{ - Attributes: make(map[string]string), - } + { + node := &structs.Node{ + Attributes: make(map[string]string), + } - cfg.NetworkSpeed = 10 - ok, err = f.Fingerprint(cfg, node) - if err != nil { - t.Fatalf("err: %v", err) - } - if !ok { - t.Fatalf("should apply") - } + cfg := &config.Config{ + NetworkSpeed: 10, + } - assertNodeAttributeContains(t, node, "unique.network.ip-address") + request := &cstructs.FingerprintRequest{Config: cfg, Node: node} + var response cstructs.FingerprintResponse + err := f.Fingerprint(request, &response) + if err != nil { + t.Fatalf("err: %v", err) + } - if node.Resources == nil || len(node.Resources.Networks) == 0 { - t.Fatal("Expected to find Network Resources") - } + assertNodeAttributeContains(t, response.Attributes, "unique.network.ip-address") - // Test at least the first Network Resource - net = node.Resources.Networks[0] - if net.IP == "" { - t.Fatal("Expected Network Resource to have an IP") - } - if net.CIDR == "" { - t.Fatal("Expected Network Resource to have a CIDR") - } - if net.Device == "" { - t.Fatal("Expected Network Resource to have a Device Name") - } - if net.MBits != 10 { - t.Fatalf("Expected Network Resource to have speed %d; got %d", 10, net.MBits) + if response.Resources == nil || len(response.Resources.Networks) == 0 { + t.Fatal("Expected to find Network Resources") + } + + // Test at least the first Network Resource + net := response.Resources.Networks[0] + if net.IP == "" { + t.Fatal("Expected Network Resource to have an IP") + } + if net.CIDR == "" { + t.Fatal("Expected Network Resource to have a CIDR") + } + if net.Device == "" { + t.Fatal("Expected Network Resource to have a Device Name") + } + if net.MBits != 10 { + t.Fatalf("Expected Network Resource to have speed %d; got %d", 10, net.MBits) + } } } @@ -294,11 +302,14 @@ func TestNetworkFingerprint_notAWS(t *testing.T) { Attributes: make(map[string]string), } - ok, err := f.Fingerprint(&config.Config{}, node) + request := &cstructs.FingerprintRequest{Config: &config.Config{}, Node: node} + var response cstructs.FingerprintResponse + err := f.Fingerprint(request, &response) if err != nil { t.Fatalf("err: %v", err) } - if ok { + + if len(response.Attributes) > 0 { t.Fatalf("Should not apply") } } diff --git a/client/fingerprint/env_gce.go b/client/fingerprint/env_gce.go index 83da63486..280914f3d 100644 --- a/client/fingerprint/env_gce.go +++ b/client/fingerprint/env_gce.go @@ -14,7 +14,7 @@ import ( "time" "github.com/hashicorp/go-cleanhttp" - "github.com/hashicorp/nomad/client/config" + cstructs "github.com/hashicorp/nomad/client/structs" "github.com/hashicorp/nomad/nomad/structs" ) @@ -131,18 +131,16 @@ func checkError(err error, logger *log.Logger, desc string) error { return err } -func (f *EnvGCEFingerprint) Fingerprint(cfg *config.Config, node *structs.Node) (bool, error) { +func (f *EnvGCEFingerprint) Fingerprint(req *cstructs.FingerprintRequest, resp *cstructs.FingerprintResponse) error { + cfg := req.Config + // Check if we should tighten the timeout if cfg.ReadBoolDefault(TightenNetworkTimeoutsConfig, false) { f.client.Timeout = 1 * time.Millisecond } if !f.isGCE() { - return false, nil - } - - if node.Links == nil { - node.Links = make(map[string]string) + return nil } // Keys and whether they should be namespaced as unique. Any key whose value @@ -159,7 +157,7 @@ func (f *EnvGCEFingerprint) Fingerprint(cfg *config.Config, node *structs.Node) for k, unique := range keys { value, err := f.Get(k, false) if err != nil { - return false, checkError(err, f.logger, k) + return checkError(err, f.logger, k) } // assume we want blank entries @@ -167,7 +165,7 @@ func (f *EnvGCEFingerprint) Fingerprint(cfg *config.Config, node *structs.Node) if unique { key = structs.UniqueNamespace(key) } - node.Attributes[key] = strings.Trim(value, "\n") + resp.AddAttribute(key, strings.Trim(value, "\n")) } // These keys need everything before the final slash removed to be usable. @@ -178,14 +176,14 @@ func (f *EnvGCEFingerprint) Fingerprint(cfg *config.Config, node *structs.Node) for k, unique := range keys { value, err := f.Get(k, false) if err != nil { - return false, checkError(err, f.logger, k) + return checkError(err, f.logger, k) } key := "platform.gce." + k if unique { key = structs.UniqueNamespace(key) } - node.Attributes[key] = strings.Trim(lastToken(value), "\n") + resp.AddAttribute(key, strings.Trim(lastToken(value), "\n")) } // Get internal and external IPs (if they exist) @@ -202,10 +200,10 @@ func (f *EnvGCEFingerprint) Fingerprint(cfg *config.Config, node *structs.Node) for _, intf := range interfaces { prefix := "platform.gce.network." + lastToken(intf.Network) uniquePrefix := "unique." + prefix - node.Attributes[prefix] = "true" - node.Attributes[uniquePrefix+".ip"] = strings.Trim(intf.Ip, "\n") + resp.AddAttribute(prefix, "true") + resp.AddAttribute(uniquePrefix+".ip", strings.Trim(intf.Ip, "\n")) for index, accessConfig := range intf.AccessConfigs { - node.Attributes[uniquePrefix+".external-ip."+strconv.Itoa(index)] = accessConfig.ExternalIp + resp.AddAttribute(uniquePrefix+".external-ip."+strconv.Itoa(index), accessConfig.ExternalIp) } } } @@ -213,7 +211,7 @@ func (f *EnvGCEFingerprint) Fingerprint(cfg *config.Config, node *structs.Node) var tagList []string value, err = f.Get("tags", false) if err != nil { - return false, checkError(err, f.logger, "tags") + return checkError(err, f.logger, "tags") } if err := json.Unmarshal([]byte(value), &tagList); err != nil { f.logger.Printf("[WARN] fingerprint.env_gce: Error decoding instance tags: %s", err.Error()) @@ -231,13 +229,13 @@ func (f *EnvGCEFingerprint) Fingerprint(cfg *config.Config, node *structs.Node) key = fmt.Sprintf("%s%s", attr, tag) } - node.Attributes[key] = "true" + resp.AddAttribute(key, "true") } var attrDict map[string]string value, err = f.Get("attributes/", true) if err != nil { - return false, checkError(err, f.logger, "attributes/") + return checkError(err, f.logger, "attributes/") } if err := json.Unmarshal([]byte(value), &attrDict); err != nil { f.logger.Printf("[WARN] fingerprint.env_gce: Error decoding instance attributes: %s", err.Error()) @@ -255,13 +253,17 @@ func (f *EnvGCEFingerprint) Fingerprint(cfg *config.Config, node *structs.Node) key = fmt.Sprintf("%s%s", attr, k) } - node.Attributes[key] = strings.Trim(v, "\n") + resp.AddAttribute(key, strings.Trim(v, "\n")) } // populate Links - node.Links["gce"] = node.Attributes["unique.platform.gce.id"] + if id, ok := resp.Attributes["unique.platform.gce.id"]; ok { + resp.AddLink("gce", id) + } - return true, nil + resp.Detected = true + + return nil } func (f *EnvGCEFingerprint) isGCE() bool { diff --git a/client/fingerprint/env_gce_test.go b/client/fingerprint/env_gce_test.go index 1e339789a..14837dca8 100644 --- a/client/fingerprint/env_gce_test.go +++ b/client/fingerprint/env_gce_test.go @@ -9,6 +9,7 @@ import ( "testing" "github.com/hashicorp/nomad/client/config" + cstructs "github.com/hashicorp/nomad/client/structs" "github.com/hashicorp/nomad/nomad/structs" ) @@ -19,13 +20,19 @@ func TestGCEFingerprint_nonGCE(t *testing.T) { Attributes: make(map[string]string), } - ok, err := f.Fingerprint(&config.Config{}, node) + request := &cstructs.FingerprintRequest{Config: &config.Config{}, Node: node} + var response cstructs.FingerprintResponse + err := f.Fingerprint(request, &response) if err != nil { t.Fatalf("err: %v", err) } - if ok { - t.Fatalf("Should be false without test server") + if response.Detected { + t.Fatalf("expected response to not be applicable") + } + + if len(response.Attributes) > 0 { + t.Fatalf("Should have zero attributes without test server") } } @@ -76,13 +83,15 @@ func testFingerprint_GCE(t *testing.T, withExternalIp bool) { os.Setenv("GCE_ENV_URL", ts.URL+"/computeMetadata/v1/instance/") f := NewEnvGCEFingerprint(testLogger()) - ok, err := f.Fingerprint(&config.Config{}, node) + request := &cstructs.FingerprintRequest{Config: &config.Config{}, Node: node} + var response cstructs.FingerprintResponse + err := f.Fingerprint(request, &response) if err != nil { t.Fatalf("err: %v", err) } - if !ok { - t.Fatalf("should apply") + if !response.Detected { + t.Fatalf("expected response to be applicable") } keys := []string{ @@ -100,40 +109,40 @@ func testFingerprint_GCE(t *testing.T, withExternalIp bool) { } for _, k := range keys { - assertNodeAttributeContains(t, node, k) + assertNodeAttributeContains(t, response.Attributes, k) } - if len(node.Links) == 0 { + if len(response.Links) == 0 { t.Fatalf("Empty links for Node in GCE Fingerprint test") } // Make sure Links contains the GCE ID. for _, k := range []string{"gce"} { - assertNodeLinksContains(t, node, k) + assertNodeLinksContains(t, response.Links, k) } - assertNodeAttributeEquals(t, node, "unique.platform.gce.id", "12345") - assertNodeAttributeEquals(t, node, "unique.platform.gce.hostname", "instance-1.c.project.internal") - assertNodeAttributeEquals(t, node, "platform.gce.zone", "us-central1-f") - assertNodeAttributeEquals(t, node, "platform.gce.machine-type", "n1-standard-1") - assertNodeAttributeEquals(t, node, "platform.gce.network.default", "true") - assertNodeAttributeEquals(t, node, "unique.platform.gce.network.default.ip", "10.240.0.5") + assertNodeAttributeEquals(t, response.Attributes, "unique.platform.gce.id", "12345") + assertNodeAttributeEquals(t, response.Attributes, "unique.platform.gce.hostname", "instance-1.c.project.internal") + assertNodeAttributeEquals(t, response.Attributes, "platform.gce.zone", "us-central1-f") + assertNodeAttributeEquals(t, response.Attributes, "platform.gce.machine-type", "n1-standard-1") + assertNodeAttributeEquals(t, response.Attributes, "platform.gce.network.default", "true") + assertNodeAttributeEquals(t, response.Attributes, "unique.platform.gce.network.default.ip", "10.240.0.5") if withExternalIp { - assertNodeAttributeEquals(t, node, "unique.platform.gce.network.default.external-ip.0", "104.44.55.66") - assertNodeAttributeEquals(t, node, "unique.platform.gce.network.default.external-ip.1", "104.44.55.67") - } else if _, ok := node.Attributes["unique.platform.gce.network.default.external-ip.0"]; ok { + assertNodeAttributeEquals(t, response.Attributes, "unique.platform.gce.network.default.external-ip.0", "104.44.55.66") + assertNodeAttributeEquals(t, response.Attributes, "unique.platform.gce.network.default.external-ip.1", "104.44.55.67") + } else if _, ok := response.Attributes["unique.platform.gce.network.default.external-ip.0"]; ok { t.Fatal("unique.platform.gce.network.default.external-ip is set without an external IP") } - assertNodeAttributeEquals(t, node, "platform.gce.scheduling.automatic-restart", "TRUE") - assertNodeAttributeEquals(t, node, "platform.gce.scheduling.on-host-maintenance", "MIGRATE") - assertNodeAttributeEquals(t, node, "platform.gce.cpu-platform", "Intel Ivy Bridge") - assertNodeAttributeEquals(t, node, "platform.gce.tag.abc", "true") - assertNodeAttributeEquals(t, node, "platform.gce.tag.def", "true") - assertNodeAttributeEquals(t, node, "unique.platform.gce.tag.foo", "true") - assertNodeAttributeEquals(t, node, "platform.gce.attr.ghi", "111") - assertNodeAttributeEquals(t, node, "platform.gce.attr.jkl", "222") - assertNodeAttributeEquals(t, node, "unique.platform.gce.attr.bar", "333") + assertNodeAttributeEquals(t, response.Attributes, "platform.gce.scheduling.automatic-restart", "TRUE") + assertNodeAttributeEquals(t, response.Attributes, "platform.gce.scheduling.on-host-maintenance", "MIGRATE") + assertNodeAttributeEquals(t, response.Attributes, "platform.gce.cpu-platform", "Intel Ivy Bridge") + assertNodeAttributeEquals(t, response.Attributes, "platform.gce.tag.abc", "true") + assertNodeAttributeEquals(t, response.Attributes, "platform.gce.tag.def", "true") + assertNodeAttributeEquals(t, response.Attributes, "unique.platform.gce.tag.foo", "true") + assertNodeAttributeEquals(t, response.Attributes, "platform.gce.attr.ghi", "111") + assertNodeAttributeEquals(t, response.Attributes, "platform.gce.attr.jkl", "222") + assertNodeAttributeEquals(t, response.Attributes, "unique.platform.gce.attr.bar", "333") } const GCE_routes = ` diff --git a/client/fingerprint/fingerprint.go b/client/fingerprint/fingerprint.go index 2d6d483b6..8a3477f51 100644 --- a/client/fingerprint/fingerprint.go +++ b/client/fingerprint/fingerprint.go @@ -6,8 +6,7 @@ import ( "sort" "time" - "github.com/hashicorp/nomad/client/config" - "github.com/hashicorp/nomad/nomad/structs" + cstructs "github.com/hashicorp/nomad/client/structs" ) // EmptyDuration is to be used by fingerprinters that are not periodic. @@ -92,8 +91,8 @@ type Factory func(*log.Logger) Fingerprint // many of them can be applied on a particular host. type Fingerprint interface { // Fingerprint is used to update properties of the Node, - // and returns if the fingerprint was applicable and a potential error. - Fingerprint(*config.Config, *structs.Node) (bool, error) + // and returns a diff of updated node attributes and a potential error. + Fingerprint(*cstructs.FingerprintRequest, *cstructs.FingerprintResponse) error // Periodic is a mechanism for the fingerprinter to indicate that it should // be run periodically. The return value is a boolean indicating if it diff --git a/client/fingerprint/fingerprint_test.go b/client/fingerprint/fingerprint_test.go index 7e62440ea..41a7e6549 100644 --- a/client/fingerprint/fingerprint_test.go +++ b/client/fingerprint/fingerprint_test.go @@ -8,6 +8,7 @@ import ( "testing" "github.com/hashicorp/nomad/client/config" + cstructs "github.com/hashicorp/nomad/client/structs" "github.com/hashicorp/nomad/nomad/structs" ) @@ -15,45 +16,63 @@ func testLogger() *log.Logger { return log.New(os.Stderr, "", log.LstdFlags) } -func assertFingerprintOK(t *testing.T, fp Fingerprint, node *structs.Node) { - ok, err := fp.Fingerprint(new(config.Config), node) +func assertFingerprintOK(t *testing.T, fp Fingerprint, node *structs.Node) *cstructs.FingerprintResponse { + request := &cstructs.FingerprintRequest{Config: new(config.Config), Node: node} + var response cstructs.FingerprintResponse + err := fp.Fingerprint(request, &response) if err != nil { t.Fatalf("Failed to fingerprint: %s", err) } - if !ok { + + if len(response.Attributes) == 0 { t.Fatalf("Failed to apply node attributes") } + + return &response } -func assertNodeAttributeContains(t *testing.T, node *structs.Node, attribute string) { - actual, found := node.Attributes[attribute] +func assertNodeAttributeContains(t *testing.T, nodeAttributes map[string]string, attribute string) { + if nodeAttributes == nil { + t.Errorf("expected an initialized map for node attributes") + return + } + + actual, found := nodeAttributes[attribute] if !found { - t.Errorf("Expected to find Attribute `%s`\n\n[DEBUG] %#v", attribute, node) + t.Errorf("Expected to find Attribute `%s`\n\n[DEBUG] %#v", attribute, nodeAttributes) return } if actual == "" { - t.Errorf("Expected non-empty Attribute value for `%s`\n\n[DEBUG] %#v", attribute, node) + t.Errorf("Expected non-empty Attribute value for `%s`\n\n[DEBUG] %#v", attribute, nodeAttributes) } } -func assertNodeAttributeEquals(t *testing.T, node *structs.Node, attribute string, expected string) { - actual, found := node.Attributes[attribute] +func assertNodeAttributeEquals(t *testing.T, nodeAttributes map[string]string, attribute string, expected string) { + if nodeAttributes == nil { + t.Errorf("expected an initialized map for node attributes") + return + } + actual, found := nodeAttributes[attribute] if !found { - t.Errorf("Expected to find Attribute `%s`; unable to check value\n\n[DEBUG] %#v", attribute, node) + t.Errorf("Expected to find Attribute `%s`; unable to check value\n\n[DEBUG] %#v", attribute, nodeAttributes) return } if expected != actual { - t.Errorf("Expected `%s` Attribute to be `%s`, found `%s`\n\n[DEBUG] %#v", attribute, expected, actual, node) + t.Errorf("Expected `%s` Attribute to be `%s`, found `%s`\n\n[DEBUG] %#v", attribute, expected, actual, nodeAttributes) } } -func assertNodeLinksContains(t *testing.T, node *structs.Node, link string) { - actual, found := node.Links[link] +func assertNodeLinksContains(t *testing.T, nodeLinks map[string]string, link string) { + if nodeLinks == nil { + t.Errorf("expected an initialized map for node links") + return + } + actual, found := nodeLinks[link] if !found { - t.Errorf("Expected to find Link `%s`\n\n[DEBUG] %#v", link, node) + t.Errorf("Expected to find Link `%s`\n\n[DEBUG]", link) return } if actual == "" { - t.Errorf("Expected non-empty Link value for `%s`\n\n[DEBUG] %#v", link, node) + t.Errorf("Expected non-empty Link value for `%s`\n\n[DEBUG]", link) } } diff --git a/client/fingerprint/host.go b/client/fingerprint/host.go index a7b8ed6c8..cfeabd4ac 100644 --- a/client/fingerprint/host.go +++ b/client/fingerprint/host.go @@ -4,8 +4,7 @@ import ( "log" "runtime" - "github.com/hashicorp/nomad/client/config" - "github.com/hashicorp/nomad/nomad/structs" + cstructs "github.com/hashicorp/nomad/client/structs" "github.com/shirou/gopsutil/host" ) @@ -21,20 +20,21 @@ func NewHostFingerprint(logger *log.Logger) Fingerprint { return f } -func (f *HostFingerprint) Fingerprint(cfg *config.Config, node *structs.Node) (bool, error) { +func (f *HostFingerprint) Fingerprint(req *cstructs.FingerprintRequest, resp *cstructs.FingerprintResponse) error { hostInfo, err := host.Info() if err != nil { f.logger.Println("[WARN] Error retrieving host information: ", err) - return false, err + return err } - node.Attributes["os.name"] = hostInfo.Platform - node.Attributes["os.version"] = hostInfo.PlatformVersion + resp.AddAttribute("os.name", hostInfo.Platform) + resp.AddAttribute("os.version", hostInfo.PlatformVersion) - node.Attributes["kernel.name"] = runtime.GOOS - node.Attributes["kernel.version"] = hostInfo.KernelVersion + resp.AddAttribute("kernel.name", runtime.GOOS) + resp.AddAttribute("kernel.version", hostInfo.KernelVersion) - node.Attributes["unique.hostname"] = hostInfo.Hostname + resp.AddAttribute("unique.hostname", hostInfo.Hostname) + resp.Detected = true - return true, nil + return nil } diff --git a/client/fingerprint/host_test.go b/client/fingerprint/host_test.go index 5a5f0cfbe..79ef870c5 100644 --- a/client/fingerprint/host_test.go +++ b/client/fingerprint/host_test.go @@ -4,6 +4,7 @@ import ( "testing" "github.com/hashicorp/nomad/client/config" + cstructs "github.com/hashicorp/nomad/client/structs" "github.com/hashicorp/nomad/nomad/structs" ) @@ -12,16 +13,24 @@ func TestHostFingerprint(t *testing.T) { node := &structs.Node{ Attributes: make(map[string]string), } - ok, err := f.Fingerprint(&config.Config{}, node) + + request := &cstructs.FingerprintRequest{Config: &config.Config{}, Node: node} + var response cstructs.FingerprintResponse + err := f.Fingerprint(request, &response) if err != nil { t.Fatalf("err: %v", err) } - if !ok { - t.Fatalf("should apply") + + if !response.Detected { + t.Fatalf("expected response to be applicable") + } + + if len(response.Attributes) == 0 { + t.Fatalf("should generate a diff of node attributes") } // Host info for _, key := range []string{"os.name", "os.version", "unique.hostname", "kernel.name"} { - assertNodeAttributeContains(t, node, key) + assertNodeAttributeContains(t, response.Attributes, key) } } diff --git a/client/fingerprint/memory.go b/client/fingerprint/memory.go index b249bebf5..c24937a43 100644 --- a/client/fingerprint/memory.go +++ b/client/fingerprint/memory.go @@ -4,7 +4,7 @@ import ( "fmt" "log" - "github.com/hashicorp/nomad/client/config" + cstructs "github.com/hashicorp/nomad/client/structs" "github.com/hashicorp/nomad/nomad/structs" "github.com/shirou/gopsutil/mem" ) @@ -23,21 +23,20 @@ func NewMemoryFingerprint(logger *log.Logger) Fingerprint { return f } -func (f *MemoryFingerprint) Fingerprint(cfg *config.Config, node *structs.Node) (bool, error) { +func (f *MemoryFingerprint) Fingerprint(req *cstructs.FingerprintRequest, resp *cstructs.FingerprintResponse) error { memInfo, err := mem.VirtualMemory() if err != nil { f.logger.Printf("[WARN] Error reading memory information: %s", err) - return false, err + return err } if memInfo.Total > 0 { - node.Attributes["memory.totalbytes"] = fmt.Sprintf("%d", memInfo.Total) + resp.AddAttribute("memory.totalbytes", fmt.Sprintf("%d", memInfo.Total)) - if node.Resources == nil { - node.Resources = &structs.Resources{} + resp.Resources = &structs.Resources{ + MemoryMB: int(memInfo.Total / 1024 / 1024), } - node.Resources.MemoryMB = int(memInfo.Total / 1024 / 1024) } - return true, nil + return nil } diff --git a/client/fingerprint/memory_test.go b/client/fingerprint/memory_test.go index 44c79c0cb..1b2cebb5b 100644 --- a/client/fingerprint/memory_test.go +++ b/client/fingerprint/memory_test.go @@ -4,6 +4,7 @@ import ( "testing" "github.com/hashicorp/nomad/client/config" + cstructs "github.com/hashicorp/nomad/client/structs" "github.com/hashicorp/nomad/nomad/structs" ) @@ -12,21 +13,20 @@ func TestMemoryFingerprint(t *testing.T) { node := &structs.Node{ Attributes: make(map[string]string), } - ok, err := f.Fingerprint(&config.Config{}, node) + + request := &cstructs.FingerprintRequest{Config: &config.Config{}, Node: node} + var response cstructs.FingerprintResponse + err := f.Fingerprint(request, &response) if err != nil { t.Fatalf("err: %v", err) } - if !ok { - t.Fatalf("should apply") - } - assertNodeAttributeContains(t, node, "memory.totalbytes") + assertNodeAttributeContains(t, response.Attributes, "memory.totalbytes") - if node.Resources == nil { - t.Fatalf("Node Resources was nil") + if response.Resources == nil { + t.Fatalf("response resources should not be nil") } - if node.Resources.MemoryMB == 0 { - t.Errorf("Expected node.Resources.MemoryMB to be non-zero") + if response.Resources.MemoryMB == 0 { + t.Fatalf("Expected node.Resources.MemoryMB to be non-zero") } - } diff --git a/client/fingerprint/network.go b/client/fingerprint/network.go index 287fb7359..9634a7969 100644 --- a/client/fingerprint/network.go +++ b/client/fingerprint/network.go @@ -6,7 +6,7 @@ import ( "net" sockaddr "github.com/hashicorp/go-sockaddr" - "github.com/hashicorp/nomad/client/config" + cstructs "github.com/hashicorp/nomad/client/structs" "github.com/hashicorp/nomad/nomad/structs" ) @@ -61,19 +61,17 @@ func NewNetworkFingerprint(logger *log.Logger) Fingerprint { return f } -func (f *NetworkFingerprint) Fingerprint(cfg *config.Config, node *structs.Node) (bool, error) { - if node.Resources == nil { - node.Resources = &structs.Resources{} - } +func (f *NetworkFingerprint) Fingerprint(req *cstructs.FingerprintRequest, resp *cstructs.FingerprintResponse) error { + cfg := req.Config // Find the named interface intf, err := f.findInterface(cfg.NetworkInterface) switch { case err != nil: - return false, fmt.Errorf("Error while detecting network interface during fingerprinting: %v", err) + return fmt.Errorf("Error while detecting network interface during fingerprinting: %v", err) case intf == nil: // No interface could be found - return false, nil + return nil } // Record the throughput of the interface @@ -94,22 +92,23 @@ func (f *NetworkFingerprint) Fingerprint(cfg *config.Config, node *structs.Node) disallowLinkLocal := cfg.ReadBoolDefault(networkDisallowLinkLocalOption, networkDisallowLinkLocalDefault) nwResources, err := f.createNetworkResources(mbits, intf, disallowLinkLocal) if err != nil { - return false, err + return err } - // Add the network resources to the node - node.Resources.Networks = nwResources + resp.Resources = &structs.Resources{ + Networks: nwResources, + } for _, nwResource := range nwResources { f.logger.Printf("[DEBUG] fingerprint.network: Detected interface %v with IP: %v", intf.Name, nwResource.IP) } // Deprecated, setting the first IP as unique IP for the node if len(nwResources) > 0 { - node.Attributes["unique.network.ip-address"] = nwResources[0].IP + resp.AddAttribute("unique.network.ip-address", nwResources[0].IP) } + resp.Detected = true - // return true, because we have a network connection - return true, nil + return nil } // createNetworkResources creates network resources for every IP diff --git a/client/fingerprint/network_test.go b/client/fingerprint/network_test.go index 78dca0df7..87b57bd4f 100644 --- a/client/fingerprint/network_test.go +++ b/client/fingerprint/network_test.go @@ -7,6 +7,7 @@ import ( "testing" "github.com/hashicorp/nomad/client/config" + cstructs "github.com/hashicorp/nomad/client/structs" "github.com/hashicorp/nomad/nomad/structs" ) @@ -189,28 +190,36 @@ func TestNetworkFingerprint_basic(t *testing.T) { } cfg := &config.Config{NetworkSpeed: 101} - ok, err := f.Fingerprint(cfg, node) + request := &cstructs.FingerprintRequest{Config: cfg, Node: node} + var response cstructs.FingerprintResponse + err := f.Fingerprint(request, &response) if err != nil { t.Fatalf("err: %v", err) } - if !ok { + + if !response.Detected { + t.Fatalf("expected response to be applicable") + } + + attributes := response.Attributes + if len(attributes) == 0 { t.Fatalf("should apply (HINT: working offline? Set env %q=y", skipOnlineTestsEnvVar) } - assertNodeAttributeContains(t, node, "unique.network.ip-address") + assertNodeAttributeContains(t, attributes, "unique.network.ip-address") - ip := node.Attributes["unique.network.ip-address"] + ip := attributes["unique.network.ip-address"] match := net.ParseIP(ip) if match == nil { t.Fatalf("Bad IP match: %s", ip) } - if node.Resources == nil || len(node.Resources.Networks) == 0 { + if response.Resources == nil || len(response.Resources.Networks) == 0 { t.Fatal("Expected to find Network Resources") } // Test at least the first Network Resource - net := node.Resources.Networks[0] + net := response.Resources.Networks[0] if net.IP == "" { t.Fatal("Expected Network Resource to not be empty") } @@ -232,13 +241,19 @@ func TestNetworkFingerprint_default_device_absent(t *testing.T) { } cfg := &config.Config{NetworkSpeed: 100, NetworkInterface: "eth0"} - ok, err := f.Fingerprint(cfg, node) + request := &cstructs.FingerprintRequest{Config: cfg, Node: node} + var response cstructs.FingerprintResponse + err := f.Fingerprint(request, &response) if err == nil { t.Fatalf("err: %v", err) } - if ok { - t.Fatalf("ok: %v", ok) + if response.Detected { + t.Fatalf("expected response to not be applicable") + } + + if len(response.Attributes) != 0 { + t.Fatalf("attributes should be zero but instead are: %v", response.Attributes) } } @@ -249,28 +264,36 @@ func TestNetworkFingerPrint_default_device(t *testing.T) { } cfg := &config.Config{NetworkSpeed: 100, NetworkInterface: "lo"} - ok, err := f.Fingerprint(cfg, node) + request := &cstructs.FingerprintRequest{Config: cfg, Node: node} + var response cstructs.FingerprintResponse + err := f.Fingerprint(request, &response) if err != nil { t.Fatalf("err: %v", err) } - if !ok { + + if !response.Detected { + t.Fatalf("expected response to be applicable") + } + + attributes := response.Attributes + if len(attributes) == 0 { t.Fatalf("should apply") } - assertNodeAttributeContains(t, node, "unique.network.ip-address") + assertNodeAttributeContains(t, attributes, "unique.network.ip-address") - ip := node.Attributes["unique.network.ip-address"] + ip := attributes["unique.network.ip-address"] match := net.ParseIP(ip) if match == nil { t.Fatalf("Bad IP match: %s", ip) } - if node.Resources == nil || len(node.Resources.Networks) == 0 { + if response.Resources == nil || len(response.Resources.Networks) == 0 { t.Fatal("Expected to find Network Resources") } // Test at least the first Network Resource - net := node.Resources.Networks[0] + net := response.Resources.Networks[0] if net.IP == "" { t.Fatal("Expected Network Resource to not be empty") } @@ -292,28 +315,32 @@ func TestNetworkFingerPrint_LinkLocal_Allowed(t *testing.T) { } cfg := &config.Config{NetworkSpeed: 100, NetworkInterface: "eth3"} - ok, err := f.Fingerprint(cfg, node) + request := &cstructs.FingerprintRequest{Config: cfg, Node: node} + var response cstructs.FingerprintResponse + err := f.Fingerprint(request, &response) if err != nil { t.Fatalf("err: %v", err) } - if !ok { - t.Fatalf("should apply") + + if !response.Detected { + t.Fatalf("expected response to be applicable") } - assertNodeAttributeContains(t, node, "unique.network.ip-address") + attributes := response.Attributes + assertNodeAttributeContains(t, attributes, "unique.network.ip-address") - ip := node.Attributes["unique.network.ip-address"] + ip := attributes["unique.network.ip-address"] match := net.ParseIP(ip) if match == nil { t.Fatalf("Bad IP match: %s", ip) } - if node.Resources == nil || len(node.Resources.Networks) == 0 { + if response.Resources == nil || len(response.Resources.Networks) == 0 { t.Fatal("Expected to find Network Resources") } // Test at least the first Network Resource - net := node.Resources.Networks[0] + net := response.Resources.Networks[0] if net.IP == "" { t.Fatal("Expected Network Resource to not be empty") } @@ -335,28 +362,36 @@ func TestNetworkFingerPrint_LinkLocal_Allowed_MixedIntf(t *testing.T) { } cfg := &config.Config{NetworkSpeed: 100, NetworkInterface: "eth4"} - ok, err := f.Fingerprint(cfg, node) + request := &cstructs.FingerprintRequest{Config: cfg, Node: node} + var response cstructs.FingerprintResponse + err := f.Fingerprint(request, &response) if err != nil { t.Fatalf("err: %v", err) } - if !ok { - t.Fatalf("should apply") + + if !response.Detected { + t.Fatalf("expected response to be applicable") } - assertNodeAttributeContains(t, node, "unique.network.ip-address") + attributes := response.Attributes + if len(attributes) == 0 { + t.Fatalf("should apply attributes") + } - ip := node.Attributes["unique.network.ip-address"] + assertNodeAttributeContains(t, attributes, "unique.network.ip-address") + + ip := attributes["unique.network.ip-address"] match := net.ParseIP(ip) if match == nil { t.Fatalf("Bad IP match: %s", ip) } - if node.Resources == nil || len(node.Resources.Networks) == 0 { + if response.Resources == nil || len(response.Resources.Networks) == 0 { t.Fatal("Expected to find Network Resources") } // Test at least the first Network Resource - net := node.Resources.Networks[0] + net := response.Resources.Networks[0] if net.IP == "" { t.Fatal("Expected Network Resource to not be empty") } @@ -387,11 +422,18 @@ func TestNetworkFingerPrint_LinkLocal_Disallowed(t *testing.T) { }, } - ok, err := f.Fingerprint(cfg, node) + request := &cstructs.FingerprintRequest{Config: cfg, Node: node} + var response cstructs.FingerprintResponse + err := f.Fingerprint(request, &response) if err != nil { t.Fatalf("err: %v", err) } - if !ok { - t.Fatalf("should not apply") + + if !response.Detected { + t.Fatalf("expected response to be applicable") + } + + if len(response.Attributes) != 0 { + t.Fatalf("should not apply attributes") } } diff --git a/client/fingerprint/nomad.go b/client/fingerprint/nomad.go index 0db894196..0a00cc026 100644 --- a/client/fingerprint/nomad.go +++ b/client/fingerprint/nomad.go @@ -3,8 +3,7 @@ package fingerprint import ( "log" - client "github.com/hashicorp/nomad/client/config" - "github.com/hashicorp/nomad/nomad/structs" + cstructs "github.com/hashicorp/nomad/client/structs" ) // NomadFingerprint is used to fingerprint the Nomad version @@ -19,8 +18,9 @@ func NewNomadFingerprint(logger *log.Logger) Fingerprint { return f } -func (f *NomadFingerprint) Fingerprint(config *client.Config, node *structs.Node) (bool, error) { - node.Attributes["nomad.version"] = config.Version.VersionNumber() - node.Attributes["nomad.revision"] = config.Version.Revision - return true, nil +func (f *NomadFingerprint) Fingerprint(req *cstructs.FingerprintRequest, resp *cstructs.FingerprintResponse) error { + resp.AddAttribute("nomad.version", req.Config.Version.VersionNumber()) + resp.AddAttribute("nomad.revision", req.Config.Version.Revision) + resp.Detected = true + return nil } diff --git a/client/fingerprint/nomad_test.go b/client/fingerprint/nomad_test.go index 730fc3c5d..2060fd8e2 100644 --- a/client/fingerprint/nomad_test.go +++ b/client/fingerprint/nomad_test.go @@ -4,6 +4,7 @@ import ( "testing" "github.com/hashicorp/nomad/client/config" + cstructs "github.com/hashicorp/nomad/client/structs" "github.com/hashicorp/nomad/nomad/structs" "github.com/hashicorp/nomad/version" ) @@ -21,17 +22,27 @@ func TestNomadFingerprint(t *testing.T) { Version: v, }, } - ok, err := f.Fingerprint(c, node) + + request := &cstructs.FingerprintRequest{Config: c, Node: node} + var response cstructs.FingerprintResponse + err := f.Fingerprint(request, &response) if err != nil { t.Fatalf("err: %v", err) } - if !ok { + + if !response.Detected { + t.Fatalf("expected response to be applicable") + } + + if len(response.Attributes) == 0 { t.Fatalf("should apply") } - if node.Attributes["nomad.version"] != v { + + if response.Attributes["nomad.version"] != v { t.Fatalf("incorrect version") } - if node.Attributes["nomad.revision"] != r { + + if response.Attributes["nomad.revision"] != r { t.Fatalf("incorrect revision") } } diff --git a/client/fingerprint/signal.go b/client/fingerprint/signal.go index d20cec07b..9aac819e1 100644 --- a/client/fingerprint/signal.go +++ b/client/fingerprint/signal.go @@ -5,8 +5,7 @@ import ( "strings" "github.com/hashicorp/consul-template/signals" - "github.com/hashicorp/nomad/client/config" - "github.com/hashicorp/nomad/nomad/structs" + cstructs "github.com/hashicorp/nomad/client/structs" ) // SignalFingerprint is used to fingerprint the available signals @@ -21,13 +20,14 @@ func NewSignalFingerprint(logger *log.Logger) Fingerprint { return f } -func (f *SignalFingerprint) Fingerprint(cfg *config.Config, node *structs.Node) (bool, error) { +func (f *SignalFingerprint) Fingerprint(req *cstructs.FingerprintRequest, resp *cstructs.FingerprintResponse) error { // Build the list of available signals sigs := make([]string, 0, len(signals.SignalLookup)) for signal := range signals.SignalLookup { sigs = append(sigs, signal) } - node.Attributes["os.signals"] = strings.Join(sigs, ",") - return true, nil + resp.AddAttribute("os.signals", strings.Join(sigs, ",")) + resp.Detected = true + return nil } diff --git a/client/fingerprint/signal_test.go b/client/fingerprint/signal_test.go index 2157cf0c5..bf61f7544 100644 --- a/client/fingerprint/signal_test.go +++ b/client/fingerprint/signal_test.go @@ -12,6 +12,6 @@ func TestSignalFingerprint(t *testing.T) { Attributes: make(map[string]string), } - assertFingerprintOK(t, fp, node) - assertNodeAttributeContains(t, node, "os.signals") + response := assertFingerprintOK(t, fp, node) + assertNodeAttributeContains(t, response.Attributes, "os.signals") } diff --git a/client/fingerprint/storage.go b/client/fingerprint/storage.go index c60f13154..6dc72fb6d 100644 --- a/client/fingerprint/storage.go +++ b/client/fingerprint/storage.go @@ -6,7 +6,7 @@ import ( "os" "strconv" - "github.com/hashicorp/nomad/client/config" + cstructs "github.com/hashicorp/nomad/client/structs" "github.com/hashicorp/nomad/nomad/structs" ) @@ -24,15 +24,8 @@ func NewStorageFingerprint(logger *log.Logger) Fingerprint { return fp } -func (f *StorageFingerprint) Fingerprint(cfg *config.Config, node *structs.Node) (bool, error) { - - // Initialize these to empty defaults - node.Attributes["unique.storage.volume"] = "" - node.Attributes["unique.storage.bytestotal"] = "" - node.Attributes["unique.storage.bytesfree"] = "" - if node.Resources == nil { - node.Resources = &structs.Resources{} - } +func (f *StorageFingerprint) Fingerprint(req *cstructs.FingerprintRequest, resp *cstructs.FingerprintResponse) error { + cfg := req.Config // Guard against unset AllocDir storageDir := cfg.AllocDir @@ -40,20 +33,24 @@ func (f *StorageFingerprint) Fingerprint(cfg *config.Config, node *structs.Node) var err error storageDir, err = os.Getwd() if err != nil { - return false, fmt.Errorf("unable to get CWD from filesystem: %s", err) + return fmt.Errorf("unable to get CWD from filesystem: %s", err) } } volume, total, free, err := f.diskFree(storageDir) if err != nil { - return false, fmt.Errorf("failed to determine disk space for %s: %v", storageDir, err) + return fmt.Errorf("failed to determine disk space for %s: %v", storageDir, err) } - node.Attributes["unique.storage.volume"] = volume - node.Attributes["unique.storage.bytestotal"] = strconv.FormatUint(total, 10) - node.Attributes["unique.storage.bytesfree"] = strconv.FormatUint(free, 10) + resp.AddAttribute("unique.storage.volume", volume) + resp.AddAttribute("unique.storage.bytestotal", strconv.FormatUint(total, 10)) + resp.AddAttribute("unique.storage.bytesfree", strconv.FormatUint(free, 10)) - node.Resources.DiskMB = int(free / bytesPerMegabyte) + // set the disk size for the response + resp.Resources = &structs.Resources{ + DiskMB: int(free / bytesPerMegabyte), + } + resp.Detected = true - return true, nil + return nil } diff --git a/client/fingerprint/storage_test.go b/client/fingerprint/storage_test.go index f975aec6d..c4388905f 100644 --- a/client/fingerprint/storage_test.go +++ b/client/fingerprint/storage_test.go @@ -13,17 +13,21 @@ func TestStorageFingerprint(t *testing.T) { Attributes: make(map[string]string), } - assertFingerprintOK(t, fp, node) + response := assertFingerprintOK(t, fp, node) - assertNodeAttributeContains(t, node, "unique.storage.volume") - assertNodeAttributeContains(t, node, "unique.storage.bytestotal") - assertNodeAttributeContains(t, node, "unique.storage.bytesfree") + if !response.Detected { + t.Fatalf("expected response to be applicable") + } - total, err := strconv.ParseInt(node.Attributes["unique.storage.bytestotal"], 10, 64) + assertNodeAttributeContains(t, response.Attributes, "unique.storage.volume") + assertNodeAttributeContains(t, response.Attributes, "unique.storage.bytestotal") + assertNodeAttributeContains(t, response.Attributes, "unique.storage.bytesfree") + + total, err := strconv.ParseInt(response.Attributes["unique.storage.bytestotal"], 10, 64) if err != nil { t.Fatalf("Failed to parse unique.storage.bytestotal: %s", err) } - free, err := strconv.ParseInt(node.Attributes["unique.storage.bytesfree"], 10, 64) + free, err := strconv.ParseInt(response.Attributes["unique.storage.bytesfree"], 10, 64) if err != nil { t.Fatalf("Failed to parse unique.storage.bytesfree: %s", err) } @@ -32,10 +36,10 @@ func TestStorageFingerprint(t *testing.T) { t.Fatalf("unique.storage.bytesfree %d is larger than unique.storage.bytestotal %d", free, total) } - if node.Resources == nil { + if response.Resources == nil { t.Fatalf("Node Resources was nil") } - if node.Resources.DiskMB == 0 { + if response.Resources.DiskMB == 0 { t.Errorf("Expected node.Resources.DiskMB to be non-zero") } } diff --git a/client/fingerprint/vault.go b/client/fingerprint/vault.go index a8728fc98..0613e98a0 100644 --- a/client/fingerprint/vault.go +++ b/client/fingerprint/vault.go @@ -7,8 +7,7 @@ import ( "strings" "time" - client "github.com/hashicorp/nomad/client/config" - "github.com/hashicorp/nomad/nomad/structs" + cstructs "github.com/hashicorp/nomad/client/structs" vapi "github.com/hashicorp/vault/api" ) @@ -29,9 +28,11 @@ func NewVaultFingerprint(logger *log.Logger) Fingerprint { return &VaultFingerprint{logger: logger, lastState: vaultUnavailable} } -func (f *VaultFingerprint) Fingerprint(config *client.Config, node *structs.Node) (bool, error) { +func (f *VaultFingerprint) Fingerprint(req *cstructs.FingerprintRequest, resp *cstructs.FingerprintResponse) error { + config := req.Config + if config.VaultConfig == nil || !config.VaultConfig.IsEnabled() { - return false, nil + return nil } // Only create the client once to avoid creating too many connections to @@ -39,35 +40,33 @@ func (f *VaultFingerprint) Fingerprint(config *client.Config, node *structs.Node if f.client == nil { vaultConfig, err := config.VaultConfig.ApiConfig() if err != nil { - return false, fmt.Errorf("Failed to initialize the Vault client config: %v", err) + return fmt.Errorf("Failed to initialize the Vault client config: %v", err) } f.client, err = vapi.NewClient(vaultConfig) if err != nil { - return false, fmt.Errorf("Failed to initialize Vault client: %s", err) + return fmt.Errorf("Failed to initialize Vault client: %s", err) } } // Connect to vault and parse its information status, err := f.client.Sys().SealStatus() if err != nil { - // Clear any attributes set by a previous fingerprint. - f.clearVaultAttributes(node) - + f.clearVaultAttributes(resp) // Print a message indicating that Vault is not available anymore if f.lastState == vaultAvailable { f.logger.Printf("[INFO] fingerprint.vault: Vault is unavailable") } f.lastState = vaultUnavailable - return false, nil + return nil } - node.Attributes["vault.accessible"] = strconv.FormatBool(true) + resp.AddAttribute("vault.accessible", strconv.FormatBool(true)) // We strip the Vault prefix because < 0.6.2 the version looks like: // status.Version = "Vault v0.6.1" - node.Attributes["vault.version"] = strings.TrimPrefix(status.Version, "Vault ") - node.Attributes["vault.cluster_id"] = status.ClusterID - node.Attributes["vault.cluster_name"] = status.ClusterName + resp.AddAttribute("vault.version", strings.TrimPrefix(status.Version, "Vault ")) + resp.AddAttribute("vault.cluster_id", status.ClusterID) + resp.AddAttribute("vault.cluster_name", status.ClusterName) // If Vault was previously unavailable print a message to indicate the Agent // is available now @@ -75,16 +74,17 @@ func (f *VaultFingerprint) Fingerprint(config *client.Config, node *structs.Node f.logger.Printf("[INFO] fingerprint.vault: Vault is available") } f.lastState = vaultAvailable - return true, nil -} - -func (f *VaultFingerprint) clearVaultAttributes(n *structs.Node) { - delete(n.Attributes, "vault.accessible") - delete(n.Attributes, "vault.version") - delete(n.Attributes, "vault.cluster_id") - delete(n.Attributes, "vault.cluster_name") + resp.Detected = true + return nil } func (f *VaultFingerprint) Periodic() (bool, time.Duration) { return true, 15 * time.Second } + +func (f *VaultFingerprint) clearVaultAttributes(r *cstructs.FingerprintResponse) { + r.RemoveAttribute("vault.accessible") + r.RemoveAttribute("vault.version") + r.RemoveAttribute("vault.cluster_id") + r.RemoveAttribute("vault.cluster_name") +} diff --git a/client/fingerprint/vault_test.go b/client/fingerprint/vault_test.go index a6835b937..25e7f1386 100644 --- a/client/fingerprint/vault_test.go +++ b/client/fingerprint/vault_test.go @@ -4,6 +4,7 @@ import ( "testing" "github.com/hashicorp/nomad/client/config" + cstructs "github.com/hashicorp/nomad/client/structs" "github.com/hashicorp/nomad/nomad/structs" "github.com/hashicorp/nomad/testutil" ) @@ -17,19 +18,22 @@ func TestVaultFingerprint(t *testing.T) { Attributes: make(map[string]string), } - config := config.DefaultConfig() - config.VaultConfig = tv.Config + conf := config.DefaultConfig() + conf.VaultConfig = tv.Config - ok, err := fp.Fingerprint(config, node) + request := &cstructs.FingerprintRequest{Config: conf, Node: node} + var response cstructs.FingerprintResponse + err := fp.Fingerprint(request, &response) if err != nil { t.Fatalf("Failed to fingerprint: %s", err) } - if !ok { - t.Fatalf("Failed to apply node attributes") + + if !response.Detected { + t.Fatalf("expected response to be applicable") } - assertNodeAttributeContains(t, node, "vault.accessible") - assertNodeAttributeContains(t, node, "vault.version") - assertNodeAttributeContains(t, node, "vault.cluster_id") - assertNodeAttributeContains(t, node, "vault.cluster_name") + assertNodeAttributeContains(t, response.Attributes, "vault.accessible") + assertNodeAttributeContains(t, response.Attributes, "vault.version") + assertNodeAttributeContains(t, response.Attributes, "vault.cluster_id") + assertNodeAttributeContains(t, response.Attributes, "vault.cluster_name") } diff --git a/client/structs/structs.go b/client/structs/structs.go index 0673ce951..97887232d 100644 --- a/client/structs/structs.go +++ b/client/structs/structs.go @@ -4,6 +4,9 @@ import ( "crypto/md5" "io" "strconv" + + "github.com/hashicorp/nomad/client/config" + "github.com/hashicorp/nomad/nomad/structs" ) // MemoryStats holds memory usage related stats @@ -184,3 +187,65 @@ func (d *DriverNetwork) Hash() []byte { } return h.Sum(nil) } + +// FingerprintRequest is a request which a fingerprinter accepts to fingerprint +// the node +type FingerprintRequest struct { + Config *config.Config + Node *structs.Node +} + +// FingerprintResponse is the response which a fingerprinter annotates with the +// results of the fingerprint method +type FingerprintResponse struct { + Attributes map[string]string + Links map[string]string + Resources *structs.Resources + + // Detected is a boolean indicating whether the fingerprinter detected + // if the resource was available + Detected bool +} + +// AddAttribute adds the name and value for a node attribute to the fingerprint +// response +func (f *FingerprintResponse) AddAttribute(name, value string) { + // initialize Attributes if it has not been already + if f.Attributes == nil { + f.Attributes = make(map[string]string, 0) + } + + f.Attributes[name] = value +} + +// RemoveAttribute sets the given attribute to empty, which will later remove +// it entirely from the node +func (f *FingerprintResponse) RemoveAttribute(name string) { + // initialize Attributes if it has not been already + if f.Attributes == nil { + f.Attributes = make(map[string]string, 0) + } + + f.Attributes[name] = "" +} + +// AddLink adds a link entry to the fingerprint response +func (f *FingerprintResponse) AddLink(name, value string) { + // initialize Links if it has not been already + if f.Links == nil { + f.Links = make(map[string]string, 0) + } + + f.Links[name] = value +} + +// RemoveLink removes a link entry from the fingerprint response. This will +// later remove it entirely from the node +func (f *FingerprintResponse) RemoveLink(name string) { + // initialize Links if it has not been already + if f.Links == nil { + f.Links = make(map[string]string, 0) + } + + f.Links[name] = "" +} diff --git a/command/agent/agent.go b/command/agent/agent.go index 43e42c1bb..eff225a3b 100644 --- a/command/agent/agent.go +++ b/command/agent/agent.go @@ -163,6 +163,12 @@ func convertServerConfig(agentConfig *Config, logOutput io.Writer) (*nomad.Confi if agentConfig.Server.NonVotingServer { conf.NonVoter = true } + if agentConfig.Server.RedundancyZone != "" { + conf.RedundancyZone = agentConfig.Server.RedundancyZone + } + if agentConfig.Server.UpgradeVersion != "" { + conf.UpgradeVersion = agentConfig.Server.UpgradeVersion + } if agentConfig.Autopilot != nil { if agentConfig.Autopilot.CleanupDeadServers != nil { conf.AutopilotConfig.CleanupDeadServers = *agentConfig.Autopilot.CleanupDeadServers @@ -176,14 +182,14 @@ func convertServerConfig(agentConfig *Config, logOutput io.Writer) (*nomad.Confi 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.EnableRedundancyZones != nil { + conf.AutopilotConfig.EnableRedundancyZones = *agentConfig.Autopilot.EnableRedundancyZones } if agentConfig.Autopilot.DisableUpgradeMigration != nil { conf.AutopilotConfig.DisableUpgradeMigration = *agentConfig.Autopilot.DisableUpgradeMigration } - if agentConfig.Autopilot.UpgradeVersionTag != "" { - conf.AutopilotConfig.UpgradeVersionTag = agentConfig.Autopilot.UpgradeVersionTag + if agentConfig.Autopilot.EnableCustomUpgrades != nil { + conf.AutopilotConfig.EnableCustomUpgrades = *agentConfig.Autopilot.EnableCustomUpgrades } } diff --git a/command/agent/command.go b/command/agent/command.go index 74bbf9614..89ecc58ea 100644 --- a/command/agent/command.go +++ b/command/agent/command.go @@ -748,6 +748,7 @@ func (c *Command) setupTelemetry(config *Config) (*metrics.InmemSink, error) { if err != nil { return inm, err } + sink.SetTags(telConfig.DataDogTags) fanout = append(fanout, sink) } diff --git a/command/agent/config-test-fixtures/basic.hcl b/command/agent/config-test-fixtures/basic.hcl index 5cf8603e7..d7731efc6 100644 --- a/command/agent/config-test-fixtures/basic.hcl +++ b/command/agent/config-test-fixtures/basic.hcl @@ -83,7 +83,9 @@ server { retry_interval = "15s" rejoin_after_leave = true non_voting_server = true - encrypt = "abc" + redundancy_zone = "foo" + upgrade_version = "0.8.0" + encrypt = "abc" } acl { enabled = true @@ -166,7 +168,7 @@ autopilot { disable_upgrade_migration = true last_contact_threshold = "12705s" max_trailing_logs = 17849 - redundancy_zone_tag = "foo" + enable_redundancy_zones = true server_stabilization_time = "23057s" - upgrade_version_tag = "bar" + enable_custom_upgrades = true } diff --git a/command/agent/config.go b/command/agent/config.go index 6cff6c378..5cd1ebf17 100644 --- a/command/agent/config.go +++ b/command/agent/config.go @@ -330,10 +330,17 @@ 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) + // (Enterprise-only) NonVotingServer is whether this server will act as a + // non-voting member of the cluster to help provide read scalability. NonVotingServer bool `mapstructure:"non_voting_server"` + // (Enterprise-only) RedundancyZone is the redundancy zone to use for this server. + RedundancyZone string `mapstructure:"redundancy_zone"` + + // (Enterprise-only) UpgradeVersion is the custom upgrade version to use when + // performing upgrade migrations. + UpgradeVersion string `mapstructure:"upgrade_version"` + // Encryption key to use for the Serf communication EncryptKey string `mapstructure:"encrypt" json:"-"` } @@ -348,6 +355,7 @@ type Telemetry struct { StatsiteAddr string `mapstructure:"statsite_address"` StatsdAddr string `mapstructure:"statsd_address"` DataDogAddr string `mapstructure:"datadog_address"` + DataDogTags []string `mapstructure:"datadog_tags"` PrometheusMetrics bool `mapstructure:"prometheus_metrics"` DisableHostname bool `mapstructure:"disable_hostname"` UseNodeName bool `mapstructure:"use_node_name"` @@ -1034,6 +1042,12 @@ func (a *ServerConfig) Merge(b *ServerConfig) *ServerConfig { if b.NonVotingServer { result.NonVotingServer = true } + if b.RedundancyZone != "" { + result.RedundancyZone = b.RedundancyZone + } + if b.UpgradeVersion != "" { + result.UpgradeVersion = b.UpgradeVersion + } if b.EncryptKey != "" { result.EncryptKey = b.EncryptKey } @@ -1157,6 +1171,9 @@ func (a *Telemetry) Merge(b *Telemetry) *Telemetry { if b.DataDogAddr != "" { result.DataDogAddr = b.DataDogAddr } + if b.DataDogTags != nil { + result.DataDogTags = b.DataDogTags + } if b.PrometheusMetrics { result.PrometheusMetrics = b.PrometheusMetrics } diff --git a/command/agent/config_parse.go b/command/agent/config_parse.go index e860a68af..774ec793c 100644 --- a/command/agent/config_parse.go +++ b/command/agent/config_parse.go @@ -9,6 +9,7 @@ import ( "time" multierror "github.com/hashicorp/go-multierror" + "github.com/hashicorp/go-version" "github.com/hashicorp/hcl" "github.com/hashicorp/hcl/hcl/ast" "github.com/hashicorp/nomad/helper" @@ -536,6 +537,8 @@ func parseServer(result **ServerConfig, list *ast.ObjectList) error { "encrypt", "authoritative_region", "non_voting_server", + "redundancy_zone", + "upgrade_version", } if err := helper.CheckHCLKeys(listVal, valid); err != nil { return err @@ -559,6 +562,12 @@ func parseServer(result **ServerConfig, list *ast.ObjectList) error { return err } + if config.UpgradeVersion != "" { + if _, err := version.NewVersion(config.UpgradeVersion); err != nil { + return fmt.Errorf("error parsing upgrade_version: %v", err) + } + } + *result = &config return nil } @@ -632,6 +641,7 @@ func parseTelemetry(result **Telemetry, list *ast.ObjectList) error { "publish_allocation_metrics", "publish_node_metrics", "datadog_address", + "datadog_tags", "prometheus_metrics", "circonus_api_token", "circonus_api_app", @@ -865,9 +875,9 @@ func parseAutopilot(result **config.AutopilotConfig, list *ast.ObjectList) error "server_stabilization_time", "last_contact_threshold", "max_trailing_logs", - "redundancy_zone_tag", + "enable_redundancy_zones", "disable_upgrade_migration", - "upgrade_version_tag", + "enable_custom_upgrades", } if err := helper.CheckHCLKeys(listVal, valid); err != nil { diff --git a/command/agent/config_parse_test.go b/command/agent/config_parse_test.go index c28989d9f..826bceb5f 100644 --- a/command/agent/config_parse_test.go +++ b/command/agent/config_parse_test.go @@ -104,6 +104,8 @@ func TestConfig_Parse(t *testing.T) { RejoinAfterLeave: true, RetryMaxAttempts: 3, NonVotingServer: true, + RedundancyZone: "foo", + UpgradeVersion: "0.8.0", EncryptKey: "abc", }, ACL: &ACLConfig{ @@ -193,9 +195,9 @@ func TestConfig_Parse(t *testing.T) { ServerStabilizationTime: 23057 * time.Second, LastContactThreshold: 12705 * time.Second, MaxTrailingLogs: 17849, - RedundancyZoneTag: "foo", + EnableRedundancyZones: &trueValue, DisableUpgradeMigration: &trueValue, - UpgradeVersionTag: "bar", + EnableCustomUpgrades: &trueValue, }, }, false, diff --git a/command/agent/config_test.go b/command/agent/config_test.go index 400b57615..35a6b59e2 100644 --- a/command/agent/config_test.go +++ b/command/agent/config_test.go @@ -56,6 +56,7 @@ func TestConfig_Merge(t *testing.T) { StatsiteAddr: "127.0.0.1:8125", StatsdAddr: "127.0.0.1:8125", DataDogAddr: "127.0.0.1:8125", + DataDogTags: []string{"cat1:tag1", "cat2:tag2"}, PrometheusMetrics: true, DisableHostname: false, DisableTaggedMetrics: true, @@ -107,6 +108,8 @@ func TestConfig_Merge(t *testing.T) { HeartbeatGrace: 30 * time.Second, MinHeartbeatTTL: 30 * time.Second, MaxHeartbeatsPerSecond: 30.0, + RedundancyZone: "foo", + UpgradeVersion: "foo", }, ACL: &ACLConfig{ Enabled: true, @@ -165,9 +168,9 @@ func TestConfig_Merge(t *testing.T) { ServerStabilizationTime: 1 * time.Second, LastContactThreshold: 1 * time.Second, MaxTrailingLogs: 1, - RedundancyZoneTag: "1", + EnableRedundancyZones: &falseValue, DisableUpgradeMigration: &falseValue, - UpgradeVersionTag: "1", + EnableCustomUpgrades: &falseValue, }, } @@ -189,6 +192,7 @@ func TestConfig_Merge(t *testing.T) { StatsiteAddr: "127.0.0.2:8125", StatsdAddr: "127.0.0.2:8125", DataDogAddr: "127.0.0.1:8125", + DataDogTags: []string{"cat1:tag1", "cat2:tag2"}, PrometheusMetrics: true, DisableHostname: true, PublishNodeMetrics: true, @@ -260,6 +264,8 @@ func TestConfig_Merge(t *testing.T) { RetryInterval: "10s", retryInterval: time.Second * 10, NonVotingServer: true, + RedundancyZone: "bar", + UpgradeVersion: "bar", }, ACL: &ACLConfig{ Enabled: true, @@ -328,9 +334,9 @@ func TestConfig_Merge(t *testing.T) { ServerStabilizationTime: 2 * time.Second, LastContactThreshold: 2 * time.Second, MaxTrailingLogs: 2, - RedundancyZoneTag: "2", + EnableRedundancyZones: &trueValue, DisableUpgradeMigration: &trueValue, - UpgradeVersionTag: "2", + EnableCustomUpgrades: &trueValue, }, } diff --git a/command/agent/http.go b/command/agent/http.go index dddb4baf8..dc147b142 100644 --- a/command/agent/http.go +++ b/command/agent/http.go @@ -18,7 +18,6 @@ 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" ) @@ -346,24 +345,6 @@ 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/operator_endpoint.go b/command/agent/operator_endpoint.go index 93db317a2..58f293375 100644 --- a/command/agent/operator_endpoint.go +++ b/command/agent/operator_endpoint.go @@ -104,19 +104,19 @@ func (s *HTTPServer) OperatorAutopilotConfiguration(resp http.ResponseWriter, re return nil, nil } - var reply autopilot.Config + var reply structs.AutopilotConfig 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), + LastContactThreshold: reply.LastContactThreshold, MaxTrailingLogs: reply.MaxTrailingLogs, - ServerStabilizationTime: api.NewReadableDuration(reply.ServerStabilizationTime), - RedundancyZoneTag: reply.RedundancyZoneTag, + ServerStabilizationTime: reply.ServerStabilizationTime, + EnableRedundancyZones: reply.EnableRedundancyZones, DisableUpgradeMigration: reply.DisableUpgradeMigration, - UpgradeVersionTag: reply.UpgradeVersionTag, + EnableCustomUpgrades: reply.EnableCustomUpgrades, CreateIndex: reply.CreateIndex, ModifyIndex: reply.ModifyIndex, } @@ -129,21 +129,20 @@ func (s *HTTPServer) OperatorAutopilotConfiguration(resp http.ResponseWriter, re s.parseToken(req, &args.AuthToken) var conf api.AutopilotConfiguration - durations := NewDurationFixer("lastcontactthreshold", "serverstabilizationtime") - if err := decodeBodyFunc(req, &conf, durations.FixupDurations); err != nil { + if err := decodeBody(req, &conf); err != nil { resp.WriteHeader(http.StatusBadRequest) fmt.Fprintf(resp, "Error parsing autopilot config: %v", err) return nil, nil } - args.Config = autopilot.Config{ + args.Config = structs.AutopilotConfig{ CleanupDeadServers: conf.CleanupDeadServers, - LastContactThreshold: conf.LastContactThreshold.Duration(), + LastContactThreshold: conf.LastContactThreshold, MaxTrailingLogs: conf.MaxTrailingLogs, - ServerStabilizationTime: conf.ServerStabilizationTime.Duration(), - RedundancyZoneTag: conf.RedundancyZoneTag, + ServerStabilizationTime: conf.ServerStabilizationTime, + EnableRedundancyZones: conf.EnableRedundancyZones, DisableUpgradeMigration: conf.DisableUpgradeMigration, - UpgradeVersionTag: conf.UpgradeVersionTag, + EnableCustomUpgrades: conf.EnableCustomUpgrades, } // Check for cas value @@ -210,7 +209,7 @@ func (s *HTTPServer) OperatorServerHealth(resp http.ResponseWriter, req *http.Re Version: server.Version, Leader: server.Leader, SerfStatus: server.SerfStatus.String(), - LastContact: api.NewReadableDuration(server.LastContact), + LastContact: server.LastContact, LastTerm: server.LastTerm, LastIndex: server.LastIndex, Healthy: server.Healthy, @@ -221,56 +220,3 @@ func (s *HTTPServer) OperatorServerHealth(resp http.ResponseWriter, req *http.Re 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 10ee36821..2d8486765 100644 --- a/command/agent/operator_endpoint_test.go +++ b/command/agent/operator_endpoint_test.go @@ -9,7 +9,6 @@ import ( "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" @@ -112,7 +111,7 @@ func TestOperator_AutopilotSetConfiguration(t *testing.T) { t.Fatalf("err: %v", err) } if resp.Code != 200 { - t.Fatalf("bad code: %d", resp.Code) + t.Fatalf("bad code: %d, %q", resp.Code, resp.Body.String()) } args := structs.GenericRequest{ @@ -121,7 +120,7 @@ func TestOperator_AutopilotSetConfiguration(t *testing.T) { }, } - var reply autopilot.Config + var reply structs.AutopilotConfig if err := s.RPC("Operator.AutopilotGetConfiguration", &args, &reply); err != nil { t.Fatalf("err: %v", err) } @@ -150,7 +149,7 @@ func TestOperator_AutopilotCASConfiguration(t *testing.T) { }, } - var reply autopilot.Config + var reply structs.AutopilotConfig if err := s.RPC("Operator.AutopilotGetConfiguration", &args, &reply); err != nil { t.Fatalf("err: %v", err) } @@ -200,7 +199,6 @@ func TestOperator_AutopilotCASConfiguration(t *testing.T) { } func TestOperator_ServerHealth(t *testing.T) { - t.Parallel() httpTest(t, func(c *Config) { c.Server.RaftProtocol = 3 }, func(s *TestAgent) { @@ -259,47 +257,3 @@ func TestOperator_ServerHealth_Unhealthy(t *testing.T) { }) }) } - -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/helpers.go b/command/helpers.go index 690220597..20a341252 100644 --- a/command/helpers.go +++ b/command/helpers.go @@ -59,7 +59,8 @@ func formatTime(t time.Time) string { // It's more confusing to display the UNIX epoch or a zero value than nothing return "" } - return t.Format("01/02/06 15:04:05 MST") + // Return ISO_8601 time format GH-3806 + return t.Format("2006-01-02T15:04:05Z07:00") } // formatUnixNanoTime is a helper for formatting time for output. diff --git a/command/node_status.go b/command/node_status.go index e14ea6fca..52dd7d15d 100644 --- a/command/node_status.go +++ b/command/node_status.go @@ -180,7 +180,7 @@ func (c *NodeStatusCommand) Run(args []string) int { out[0] = "ID|DC|Name|Class|" if c.verbose { - out[0] += "Version|" + out[0] += "Address|Version|" } out[0] += "Drain|Status" @@ -196,8 +196,8 @@ func (c *NodeStatusCommand) Run(args []string) int { node.Name, node.NodeClass) if c.verbose { - out[i+1] += fmt.Sprintf("|%s", - node.Version) + out[i+1] += fmt.Sprintf("|%s|%s", + node.Address, node.Version) } out[i+1] += fmt.Sprintf("|%v|%s", node.Drain, diff --git a/command/operator_autopilot_get.go b/command/operator_autopilot_get.go index b533b4749..c85c4e5c4 100644 --- a/command/operator_autopilot_get.go +++ b/command/operator_autopilot_get.go @@ -45,9 +45,9 @@ func (c *OperatorAutopilotGetCommand) Run(args []string) int { 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("EnableRedundancyZones = %v", config.EnableRedundancyZones)) c.Ui.Output(fmt.Sprintf("DisableUpgradeMigration = %v", config.DisableUpgradeMigration)) - c.Ui.Output(fmt.Sprintf("UpgradeVersionTag = %q", config.UpgradeVersionTag)) + c.Ui.Output(fmt.Sprintf("EnableCustomUpgrades = %v", config.EnableCustomUpgrades)) return 0 } diff --git a/command/operator_autopilot_set.go b/command/operator_autopilot_set.go index bacefe339..3e8873279 100644 --- a/command/operator_autopilot_set.go +++ b/command/operator_autopilot_set.go @@ -3,10 +3,8 @@ package command import ( "fmt" "strings" - "time" "github.com/hashicorp/consul/command/flags" - "github.com/hashicorp/nomad/api" "github.com/posener/complete" ) @@ -21,9 +19,9 @@ func (c *OperatorAutopilotSetCommand) AutocompleteFlags() complete.Flags { "-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, + "-enable-redundancy-zones": complete.PredictNothing, + "-disable-upgrade-migration": complete.PredictNothing, + "-enable-custom-upgrades": complete.PredictNothing, }) } @@ -36,9 +34,9 @@ func (c *OperatorAutopilotSetCommand) Run(args []string) int { var maxTrailingLogs flags.UintValue var lastContactThreshold flags.DurationValue var serverStabilizationTime flags.DurationValue - var redundancyZoneTag flags.StringValue + var enableRedundancyZones flags.BoolValue var disableUpgradeMigration flags.BoolValue - var upgradeVersionTag flags.StringValue + var enableCustomUpgrades flags.BoolValue f := c.Meta.FlagSet("autopilot", FlagSetClient) f.Usage = func() { c.Ui.Output(c.Help()) } @@ -47,9 +45,9 @@ func (c *OperatorAutopilotSetCommand) Run(args []string) int { 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(&enableRedundancyZones, "enable-redundancy-zones", "") f.Var(&disableUpgradeMigration, "disable-upgrade-migration", "") - f.Var(&upgradeVersionTag, "upgrade-version-tag", "") + f.Var(&enableCustomUpgrades, "enable-custom-upgrades", "") if err := f.Parse(args); err != nil { c.Ui.Error(fmt.Sprintf("Failed to parse args: %v", err)) @@ -73,21 +71,15 @@ func (c *OperatorAutopilotSetCommand) Run(args []string) int { // Update the config values based on the set flags. cleanupDeadServers.Merge(&conf.CleanupDeadServers) - redundancyZoneTag.Merge(&conf.RedundancyZoneTag) + enableRedundancyZones.Merge(&conf.EnableRedundancyZones) disableUpgradeMigration.Merge(&conf.DisableUpgradeMigration) - upgradeVersionTag.Merge(&conf.UpgradeVersionTag) + enableRedundancyZones.Merge(&conf.EnableCustomUpgrades) 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) + lastContactThreshold.Merge(&conf.LastContactThreshold) + serverStabilizationTime.Merge(&conf.ServerStabilizationTime) // Check-and-set the new configuration. result, err := operator.AutopilotCASConfiguration(conf, nil) diff --git a/command/operator_autopilot_set_test.go b/command/operator_autopilot_set_test.go index 8991ce51e..f76f75b5d 100644 --- a/command/operator_autopilot_set_test.go +++ b/command/operator_autopilot_set_test.go @@ -53,10 +53,10 @@ func TestOperatorAutopilotSetConfigCommmand(t *testing.T) { if conf.MaxTrailingLogs != 99 { t.Fatalf("bad: %#v", conf) } - if conf.LastContactThreshold.Duration() != 123*time.Millisecond { + if conf.LastContactThreshold != 123*time.Millisecond { t.Fatalf("bad: %#v", conf) } - if conf.ServerStabilizationTime.Duration() != 123*time.Millisecond { + if conf.ServerStabilizationTime != 123*time.Millisecond { t.Fatalf("bad: %#v", conf) } } diff --git a/nomad/autopilot.go b/nomad/autopilot.go index 5fd2a9b37..399b3458f 100644 --- a/nomad/autopilot.go +++ b/nomad/autopilot.go @@ -10,13 +10,45 @@ import ( "github.com/hashicorp/serf/serf" ) +const ( + // AutopilotRZTag is the Serf tag to use for the redundancy zone value + // when passing the server metadata to Autopilot. + AutopilotRZTag = "ap_zone" + + // AutopilotRZTag is the Serf tag to use for the custom version value + // when passing the server metadata to Autopilot. + AutopilotVersionTag = "ap_version" +) + // AutopilotDelegate is a Nomad delegate for autopilot operations. type AutopilotDelegate struct { server *Server } func (d *AutopilotDelegate) AutopilotConfig() *autopilot.Config { - return d.server.getOrCreateAutopilotConfig() + c := d.server.getOrCreateAutopilotConfig() + if c == nil { + return nil + } + + conf := &autopilot.Config{ + CleanupDeadServers: c.CleanupDeadServers, + LastContactThreshold: c.LastContactThreshold, + MaxTrailingLogs: c.MaxTrailingLogs, + ServerStabilizationTime: c.ServerStabilizationTime, + DisableUpgradeMigration: c.DisableUpgradeMigration, + ModifyIndex: c.ModifyIndex, + CreateIndex: c.CreateIndex, + } + + if c.EnableRedundancyZones { + conf.RedundancyZoneTag = AutopilotRZTag + } + if c.EnableCustomUpgrades { + conf.UpgradeVersionTag = AutopilotVersionTag + } + + return conf } func (d *AutopilotDelegate) FetchStats(ctx context.Context, servers []serf.Member) map[string]*autopilot.ServerStats { diff --git a/nomad/autopilot_test.go b/nomad/autopilot_test.go index 6511c8be9..13cee5044 100644 --- a/nomad/autopilot_test.go +++ b/nomad/autopilot_test.go @@ -270,8 +270,11 @@ func TestAutopilot_CleanupStaleRaftServer(t *testing.T) { 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) + addr := fmt.Sprintf("127.0.0.1:%d", s4.config.RPCAddr.Port) + future := s1.raft.AddVoter(raft.ServerID(s4.config.NodeID), raft.ServerAddress(addr), 0, 0) + if err := future.Error(); err != nil { + t.Fatal(err) + } // Verify we have 4 peers peers, err := s1.numPeers() diff --git a/nomad/config.go b/nomad/config.go index 5fd8dad8e..a2c553eb2 100644 --- a/nomad/config.go +++ b/nomad/config.go @@ -8,7 +8,6 @@ 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" @@ -98,6 +97,13 @@ type Config struct { // as a voting member of the Raft cluster. NonVoter bool + // (Enterprise-only) RedundancyZone is the redundancy zone to use for this server. + RedundancyZone string + + // (Enterprise-only) UpgradeVersion is the custom upgrade version to use when + // performing upgrade migrations. + UpgradeVersion string + // SerfConfig is the configuration for the serf cluster SerfConfig *serf.Config @@ -269,7 +275,7 @@ type Config struct { // AutopilotConfig is used to apply the initial autopilot config when // bootstrapping. - AutopilotConfig *autopilot.Config + AutopilotConfig *structs.AutopilotConfig // ServerHealthInterval is the frequency with which the health of the // servers in the cluster will be updated. @@ -339,7 +345,7 @@ func DefaultConfig() *Config { TLSConfig: &config.TLSConfig{}, ReplicationBackoff: 30 * time.Second, SentinelGCInterval: 30 * time.Second, - AutopilotConfig: &autopilot.Config{ + AutopilotConfig: &structs.AutopilotConfig{ CleanupDeadServers: true, LastContactThreshold: 200 * time.Millisecond, MaxTrailingLogs: 250, diff --git a/nomad/fsm_test.go b/nomad/fsm_test.go index 90c0b6c12..aa16be373 100644 --- a/nomad/fsm_test.go +++ b/nomad/fsm_test.go @@ -10,7 +10,6 @@ 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" @@ -2319,7 +2318,7 @@ func TestFSM_Autopilot(t *testing.T) { // Set the autopilot config using a request. req := structs.AutopilotSetConfigRequest{ Datacenter: "dc1", - Config: autopilot.Config{ + Config: structs.AutopilotConfig{ CleanupDeadServers: true, LastContactThreshold: 10 * time.Second, MaxTrailingLogs: 300, diff --git a/nomad/leader.go b/nomad/leader.go index 712fa75b9..4d1a571cc 100644 --- a/nomad/leader.go +++ b/nomad/leader.go @@ -13,7 +13,6 @@ 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" @@ -1174,7 +1173,7 @@ func diffACLTokens(state *state.StateStore, minIndex uint64, remoteList []*struc } // getOrCreateAutopilotConfig is used to get the autopilot config, initializing it if necessary -func (s *Server) getOrCreateAutopilotConfig() *autopilot.Config { +func (s *Server) getOrCreateAutopilotConfig() *structs.AutopilotConfig { state := s.fsm.State() _, config, err := state.AutopilotConfig() if err != nil { diff --git a/nomad/operator_endpoint.go b/nomad/operator_endpoint.go index b0a54d700..e6c992132 100644 --- a/nomad/operator_endpoint.go +++ b/nomad/operator_endpoint.go @@ -192,7 +192,7 @@ REMOVE: } // AutopilotGetConfiguration is used to retrieve the current Autopilot configuration. -func (op *Operator) AutopilotGetConfiguration(args *structs.GenericRequest, reply *autopilot.Config) error { +func (op *Operator) AutopilotGetConfiguration(args *structs.GenericRequest, reply *structs.AutopilotConfig) error { if done, err := op.srv.forward("Operator.AutopilotGetConfiguration", args, args, reply); done { return err } diff --git a/nomad/server.go b/nomad/server.go index d3faa60f5..09b7964c7 100644 --- a/nomad/server.go +++ b/nomad/server.go @@ -1105,6 +1105,12 @@ func (s *Server) setupSerf(conf *serf.Config, ch chan serf.Event, path string) ( if s.config.NonVoter { conf.Tags["nonvoter"] = "1" } + if s.config.RedundancyZone != "" { + conf.Tags[AutopilotRZTag] = s.config.RedundancyZone + } + if s.config.UpgradeVersion != "" { + conf.Tags[AutopilotVersionTag] = s.config.UpgradeVersion + } conf.MemberlistConfig.LogOutput = s.config.LogOutput conf.LogOutput = s.config.LogOutput conf.EventCh = ch diff --git a/nomad/state/autopilot.go b/nomad/state/autopilot.go index 65654ca79..83613817d 100644 --- a/nomad/state/autopilot.go +++ b/nomad/state/autopilot.go @@ -3,8 +3,8 @@ package state import ( "fmt" - "github.com/hashicorp/consul/agent/consul/autopilot" "github.com/hashicorp/go-memdb" + "github.com/hashicorp/nomad/nomad/structs" ) // autopilotConfigTableSchema returns a new table schema used for storing @@ -26,7 +26,7 @@ func autopilotConfigTableSchema() *memdb.TableSchema { } // AutopilotConfig is used to get the current Autopilot configuration. -func (s *StateStore) AutopilotConfig() (uint64, *autopilot.Config, error) { +func (s *StateStore) AutopilotConfig() (uint64, *structs.AutopilotConfig, error) { tx := s.db.Txn(false) defer tx.Abort() @@ -36,7 +36,7 @@ func (s *StateStore) AutopilotConfig() (uint64, *autopilot.Config, error) { return 0, nil, fmt.Errorf("failed autopilot config lookup: %s", err) } - config, ok := c.(*autopilot.Config) + config, ok := c.(*structs.AutopilotConfig) if !ok { return 0, nil, nil } @@ -45,7 +45,7 @@ func (s *StateStore) AutopilotConfig() (uint64, *autopilot.Config, error) { } // AutopilotSetConfig is used to set the current Autopilot configuration. -func (s *StateStore) AutopilotSetConfig(idx uint64, config *autopilot.Config) error { +func (s *StateStore) AutopilotSetConfig(idx uint64, config *structs.AutopilotConfig) error { tx := s.db.Txn(true) defer tx.Abort() @@ -58,7 +58,7 @@ func (s *StateStore) AutopilotSetConfig(idx uint64, config *autopilot.Config) er // 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) { +func (s *StateStore) AutopilotCASConfig(idx, cidx uint64, config *structs.AutopilotConfig) (bool, error) { tx := s.db.Txn(true) defer tx.Abort() @@ -71,7 +71,7 @@ func (s *StateStore) AutopilotCASConfig(idx, cidx uint64, config *autopilot.Conf // 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) + e, ok := existing.(*structs.AutopilotConfig) if !ok || e.ModifyIndex != cidx { return false, nil } @@ -82,7 +82,7 @@ func (s *StateStore) AutopilotCASConfig(idx, cidx uint64, config *autopilot.Conf return true, nil } -func (s *StateStore) autopilotSetConfigTxn(idx uint64, tx *memdb.Txn, config *autopilot.Config) error { +func (s *StateStore) autopilotSetConfigTxn(idx uint64, tx *memdb.Txn, config *structs.AutopilotConfig) error { // Check for an existing config existing, err := tx.First("autopilot-config", "id") if err != nil { @@ -91,7 +91,7 @@ func (s *StateStore) autopilotSetConfigTxn(idx uint64, tx *memdb.Txn, config *au // Set the indexes. if existing != nil { - config.CreateIndex = existing.(*autopilot.Config).CreateIndex + config.CreateIndex = existing.(*structs.AutopilotConfig).CreateIndex } else { config.CreateIndex = idx } diff --git a/nomad/state/autopilot_test.go b/nomad/state/autopilot_test.go index 59bf7b417..a43ddebe8 100644 --- a/nomad/state/autopilot_test.go +++ b/nomad/state/autopilot_test.go @@ -5,20 +5,20 @@ import ( "testing" "time" - "github.com/hashicorp/consul/agent/consul/autopilot" + "github.com/hashicorp/nomad/nomad/structs" ) func TestStateStore_Autopilot(t *testing.T) { s := testStateStore(t) - expected := &autopilot.Config{ + expected := &structs.AutopilotConfig{ CleanupDeadServers: true, LastContactThreshold: 5 * time.Second, MaxTrailingLogs: 500, ServerStabilizationTime: 100 * time.Second, - RedundancyZoneTag: "az", + EnableRedundancyZones: true, DisableUpgradeMigration: true, - UpgradeVersionTag: "build", + EnableCustomUpgrades: true, } if err := s.AutopilotSetConfig(0, expected); err != nil { @@ -40,7 +40,7 @@ func TestStateStore_Autopilot(t *testing.T) { func TestStateStore_AutopilotCAS(t *testing.T) { s := testStateStore(t) - expected := &autopilot.Config{ + expected := &structs.AutopilotConfig{ CleanupDeadServers: true, } @@ -52,7 +52,7 @@ func TestStateStore_AutopilotCAS(t *testing.T) { } // Do a CAS with an index lower than the entry - ok, err := s.AutopilotCASConfig(2, 0, &autopilot.Config{ + ok, err := s.AutopilotCASConfig(2, 0, &structs.AutopilotConfig{ CleanupDeadServers: false, }) if ok || err != nil { @@ -73,7 +73,7 @@ func TestStateStore_AutopilotCAS(t *testing.T) { } // Do another CAS, this time with the correct index - ok, err = s.AutopilotCASConfig(2, 1, &autopilot.Config{ + ok, err = s.AutopilotCASConfig(2, 1, &structs.AutopilotConfig{ CleanupDeadServers: false, }) if !ok || err != nil { diff --git a/nomad/structs/config/autopilot.go b/nomad/structs/config/autopilot.go index b1501b82f..ffa52bc09 100644 --- a/nomad/structs/config/autopilot.go +++ b/nomad/structs/config/autopilot.go @@ -24,25 +24,23 @@ type AutopilotConfig struct { // 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) EnableRedundancyZones specifies whether to enable redundancy zones. + EnableRedundancyZones *bool `mapstructure:"enable_redundancy_zones"` // (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"` + // (Enterprise-only) EnableCustomUpgrades specifies whether to enable using custom + // upgrade versions when performing migrations. + EnableCustomUpgrades *bool `mapstructure:"enable_custom_upgrades"` } // 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, @@ -64,14 +62,14 @@ func (a *AutopilotConfig) Merge(b *AutopilotConfig) *AutopilotConfig { if b.MaxTrailingLogs != 0 { result.MaxTrailingLogs = b.MaxTrailingLogs } - if b.RedundancyZoneTag != "" { - result.RedundancyZoneTag = b.RedundancyZoneTag + if b.EnableRedundancyZones != nil { + result.EnableRedundancyZones = b.EnableRedundancyZones } if b.DisableUpgradeMigration != nil { result.DisableUpgradeMigration = helper.BoolToPtr(*b.DisableUpgradeMigration) } - if b.UpgradeVersionTag != "" { - result.UpgradeVersionTag = b.UpgradeVersionTag + if b.EnableCustomUpgrades != nil { + result.EnableCustomUpgrades = b.EnableCustomUpgrades } return result @@ -90,9 +88,15 @@ func (a *AutopilotConfig) Copy() *AutopilotConfig { if a.CleanupDeadServers != nil { nc.CleanupDeadServers = helper.BoolToPtr(*a.CleanupDeadServers) } + if a.EnableRedundancyZones != nil { + nc.EnableRedundancyZones = helper.BoolToPtr(*a.EnableRedundancyZones) + } if a.DisableUpgradeMigration != nil { nc.DisableUpgradeMigration = helper.BoolToPtr(*a.DisableUpgradeMigration) } + if a.EnableCustomUpgrades != nil { + nc.EnableCustomUpgrades = helper.BoolToPtr(*a.EnableCustomUpgrades) + } return nc } diff --git a/nomad/structs/config/autopilot_test.go b/nomad/structs/config/autopilot_test.go index 1dcb725a0..644541c0a 100644 --- a/nomad/structs/config/autopilot_test.go +++ b/nomad/structs/config/autopilot_test.go @@ -14,9 +14,9 @@ func TestAutopilotConfig_Merge(t *testing.T) { ServerStabilizationTime: 1 * time.Second, LastContactThreshold: 1 * time.Second, MaxTrailingLogs: 1, - RedundancyZoneTag: "1", + EnableRedundancyZones: &trueValue, DisableUpgradeMigration: &falseValue, - UpgradeVersionTag: "1", + EnableCustomUpgrades: &trueValue, } c2 := &AutopilotConfig{ @@ -24,9 +24,9 @@ func TestAutopilotConfig_Merge(t *testing.T) { ServerStabilizationTime: 2 * time.Second, LastContactThreshold: 2 * time.Second, MaxTrailingLogs: 2, - RedundancyZoneTag: "2", + EnableRedundancyZones: nil, DisableUpgradeMigration: nil, - UpgradeVersionTag: "2", + EnableCustomUpgrades: nil, } e := &AutopilotConfig{ @@ -34,9 +34,9 @@ func TestAutopilotConfig_Merge(t *testing.T) { ServerStabilizationTime: 2 * time.Second, LastContactThreshold: 2 * time.Second, MaxTrailingLogs: 2, - RedundancyZoneTag: "2", + EnableRedundancyZones: &trueValue, DisableUpgradeMigration: &falseValue, - UpgradeVersionTag: "2", + EnableCustomUpgrades: &trueValue, } result := c1.Merge(c2) diff --git a/nomad/structs/operator.go b/nomad/structs/operator.go index fe83ec86f..43bd6a420 100644 --- a/nomad/structs/operator.go +++ b/nomad/structs/operator.go @@ -1,7 +1,8 @@ package structs import ( - "github.com/hashicorp/consul/agent/consul/autopilot" + "time" + "github.com/hashicorp/raft" ) @@ -69,7 +70,7 @@ type AutopilotSetConfigRequest struct { Datacenter string // Config is the new Autopilot configuration to use. - Config autopilot.Config + Config AutopilotConfig // CAS controls whether to use check-and-set semantics for this request. CAS bool @@ -82,3 +83,39 @@ type AutopilotSetConfigRequest struct { func (op *AutopilotSetConfigRequest) RequestDatacenter() string { return op.Datacenter } + +// AutopilotConfig is the internal config for the Autopilot mechanism. +type AutopilotConfig struct { + // CleanupDeadServers controls whether to remove dead servers when a new + // server is added to the Raft peers. + CleanupDeadServers bool + + // 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 + + // LastContactThreshold is the limit on the amount of time a server can go + // without leader contact before being considered unhealthy. + LastContactThreshold time.Duration + + // MaxTrailingLogs is the amount of entries in the Raft Log that a server can + // be behind before being considered unhealthy. + MaxTrailingLogs uint64 + + // (Enterprise-only) EnableRedundancyZones specifies whether to enable redundancy zones. + EnableRedundancyZones bool + + // (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) EnableCustomUpgrades specifies whether to enable using custom + // upgrade versions when performing migrations. + EnableCustomUpgrades bool + + // CreateIndex/ModifyIndex store the create/modify indexes of this configuration. + CreateIndex uint64 + ModifyIndex uint64 +} diff --git a/nomad/structs/structs.go b/nomad/structs/structs.go index 465df281b..72a46e063 100644 --- a/nomad/structs/structs.go +++ b/nomad/structs/structs.go @@ -1173,7 +1173,11 @@ func (n *Node) TerminalStatus() bool { // Stub returns a summarized version of the node func (n *Node) Stub() *NodeListStub { + + addr, _, _ := net.SplitHostPort(n.HTTPAddr) + return &NodeListStub{ + Address: addr, ID: n.ID, Datacenter: n.Datacenter, Name: n.Name, @@ -1190,6 +1194,7 @@ func (n *Node) Stub() *NodeListStub { // NodeListStub is used to return a subset of job information // for the job list type NodeListStub struct { + Address string ID string Datacenter string Name string diff --git a/nomad/util.go b/nomad/util.go index 8b02a585f..be01dc418 100644 --- a/nomad/util.go +++ b/nomad/util.go @@ -46,7 +46,6 @@ type serverParts struct { MinorVersion int Build version.Version RaftVersion int - NonVoter bool Addr net.Addr RPCAddr net.Addr Status serf.MemberStatus @@ -71,7 +70,6 @@ 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 expectStr, ok := m.Tags["expect"] @@ -140,7 +138,6 @@ func isNomadServer(m serf.Member) (bool, *serverParts) { MinorVersion: minorVersion, Build: *buildVersion, RaftVersion: raftVsn, - NonVoter: nonVoter, Status: m.Status, } return true, parts diff --git a/nomad/util_test.go b/nomad/util_test.go index 12db5100c..3e1db6296 100644 --- a/nomad/util_test.go +++ b/nomad/util_test.go @@ -24,7 +24,6 @@ func TestIsNomadServer(t *testing.T) { "port": "10000", "vsn": "1", "raft_vsn": "2", - "nonvoter": "1", "build": "0.7.0+ent", }, } @@ -51,9 +50,6 @@ func TestIsNomadServer(t *testing.T) { 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 { diff --git a/scripts/travis-linux.sh b/scripts/travis-linux.sh index 4c402b797..4d5986788 100644 --- a/scripts/travis-linux.sh +++ b/scripts/travis-linux.sh @@ -2,6 +2,10 @@ set -o errexit +#enable ipv6 +echo '{"ipv6":true, "fixed-cidr-v6":"2001:db8:1::/64"}' | sudo tee /etc/docker/daemon.json +sudo service docker restart + apt-get update apt-get install -y liblxc1 lxc-dev lxc shellcheck apt-get install -y qemu diff --git a/ui/app/adapters/job.js b/ui/app/adapters/job.js index 80398adac..c783c6ff2 100644 --- a/ui/app/adapters/job.js +++ b/ui/app/adapters/job.js @@ -65,4 +65,17 @@ export default ApplicationAdapter.extend({ const url = this.buildURL('job', name, job, 'findRecord'); return this.ajax(url, 'GET', { data: assign(this.buildQuery() || {}, namespaceQuery) }); }, + + forcePeriodic(job) { + if (job.get('periodic')) { + const [name, namespace] = JSON.parse(job.get('id')); + let url = `${this.buildURL('job', name, job, 'findRecord')}/periodic/force`; + + if (namespace) { + url += `?namespace=${namespace}`; + } + + return this.ajax(url, 'POST'); + } + }, }); diff --git a/ui/app/components/allocation-status-bar.js b/ui/app/components/allocation-status-bar.js index 7f2d1e2fc..5322ac97e 100644 --- a/ui/app/components/allocation-status-bar.js +++ b/ui/app/components/allocation-status-bar.js @@ -6,6 +6,8 @@ export default DistributionBar.extend({ allocationContainer: null, + 'data-test-allocation-status-bar': true, + data: computed( 'allocationContainer.{queuedAllocs,completeAllocs,failedAllocs,runningAllocs,startingAllocs}', function() { diff --git a/ui/app/components/children-status-bar.js b/ui/app/components/children-status-bar.js new file mode 100644 index 000000000..b95b4f240 --- /dev/null +++ b/ui/app/components/children-status-bar.js @@ -0,0 +1,27 @@ +import { computed } from '@ember/object'; +import DistributionBar from './distribution-bar'; + +export default DistributionBar.extend({ + layoutName: 'components/distribution-bar', + + job: null, + + 'data-test-children-status-bar': true, + + data: computed('job.{pendingChildren,runningChildren,deadChildren}', function() { + if (!this.get('job')) { + return []; + } + + const children = this.get('job').getProperties( + 'pendingChildren', + 'runningChildren', + 'deadChildren' + ); + return [ + { label: 'Pending', value: children.pendingChildren, className: 'queued' }, + { label: 'Running', value: children.runningChildren, className: 'running' }, + { label: 'Dead', value: children.deadChildren, className: 'complete' }, + ]; + }), +}); diff --git a/ui/app/components/job-page/abstract.js b/ui/app/components/job-page/abstract.js new file mode 100644 index 000000000..eb80479d7 --- /dev/null +++ b/ui/app/components/job-page/abstract.js @@ -0,0 +1,29 @@ +import Component from '@ember/component'; +import { computed } from '@ember/object'; +import { inject as service } from '@ember/service'; + +export default Component.extend({ + system: service(), + + job: null, + + // Provide a value that is bound to a query param + sortProperty: null, + sortDescending: null, + + // Provide actions that require routing + onNamespaceChange() {}, + gotoTaskGroup() {}, + gotoJob() {}, + + breadcrumbs: computed('job.{name,id}', function() { + const job = this.get('job'); + return [ + { label: 'Jobs', args: ['jobs'] }, + { + label: job.get('name'), + args: ['jobs.job', job], + }, + ]; + }), +}); diff --git a/ui/app/components/job-page/batch.js b/ui/app/components/job-page/batch.js new file mode 100644 index 000000000..559b3c8b8 --- /dev/null +++ b/ui/app/components/job-page/batch.js @@ -0,0 +1,3 @@ +import AbstractJobPage from './abstract'; + +export default AbstractJobPage.extend(); diff --git a/ui/app/components/job-page/parameterized-child.js b/ui/app/components/job-page/parameterized-child.js new file mode 100644 index 000000000..841c6fa60 --- /dev/null +++ b/ui/app/components/job-page/parameterized-child.js @@ -0,0 +1,16 @@ +import { computed } from '@ember/object'; +import { alias } from '@ember/object/computed'; +import PeriodicChildJobPage from './periodic-child'; + +export default PeriodicChildJobPage.extend({ + payload: alias('job.decodedPayload'), + payloadJSON: computed('payload', function() { + let json; + try { + json = JSON.parse(this.get('payload')); + } catch (e) { + // Swallow error and fall back to plain text rendering + } + return json; + }), +}); diff --git a/ui/app/components/job-page/parameterized.js b/ui/app/components/job-page/parameterized.js new file mode 100644 index 000000000..559b3c8b8 --- /dev/null +++ b/ui/app/components/job-page/parameterized.js @@ -0,0 +1,3 @@ +import AbstractJobPage from './abstract'; + +export default AbstractJobPage.extend(); diff --git a/ui/app/components/job-page/parts/children.js b/ui/app/components/job-page/parts/children.js new file mode 100644 index 000000000..30772cd0a --- /dev/null +++ b/ui/app/components/job-page/parts/children.js @@ -0,0 +1,31 @@ +import Component from '@ember/component'; +import { computed } from '@ember/object'; +import { alias } from '@ember/object/computed'; +import Sortable from 'nomad-ui/mixins/sortable'; + +export default Component.extend(Sortable, { + job: null, + + classNames: ['boxed-section'], + + // Provide a value that is bound to a query param + sortProperty: null, + sortDescending: null, + currentPage: null, + + // Provide an action with access to the router + gotoJob() {}, + + pageSize: 10, + + taskGroups: computed('job.taskGroups.[]', function() { + return this.get('job.taskGroups') || []; + }), + + children: computed('job.children.[]', function() { + return this.get('job.children') || []; + }), + + listToSort: alias('children'), + sortedChildren: alias('listSorted'), +}); diff --git a/ui/app/components/job-page/parts/evaluations.js b/ui/app/components/job-page/parts/evaluations.js new file mode 100644 index 000000000..33f6054a7 --- /dev/null +++ b/ui/app/components/job-page/parts/evaluations.js @@ -0,0 +1,12 @@ +import Component from '@ember/component'; +import { computed } from '@ember/object'; + +export default Component.extend({ + job: null, + + classNames: ['boxed-section'], + + sortedEvaluations: computed('job.evaluations.@each.modifyIndex', function() { + return (this.get('job.evaluations') || []).sortBy('modifyIndex').reverse(); + }), +}); diff --git a/ui/app/components/job-page/parts/placement-failures.js b/ui/app/components/job-page/parts/placement-failures.js new file mode 100644 index 000000000..7df4236d8 --- /dev/null +++ b/ui/app/components/job-page/parts/placement-failures.js @@ -0,0 +1,6 @@ +import Component from '@ember/component'; + +export default Component.extend({ + job: null, + tagName: '', +}); diff --git a/ui/app/components/job-page/parts/running-deployment.js b/ui/app/components/job-page/parts/running-deployment.js new file mode 100644 index 000000000..7df4236d8 --- /dev/null +++ b/ui/app/components/job-page/parts/running-deployment.js @@ -0,0 +1,6 @@ +import Component from '@ember/component'; + +export default Component.extend({ + job: null, + tagName: '', +}); diff --git a/ui/app/components/job-page/parts/summary.js b/ui/app/components/job-page/parts/summary.js new file mode 100644 index 000000000..0ff44fc5a --- /dev/null +++ b/ui/app/components/job-page/parts/summary.js @@ -0,0 +1,7 @@ +import Component from '@ember/component'; + +export default Component.extend({ + job: null, + + classNames: ['boxed-section'], +}); diff --git a/ui/app/components/job-page/parts/task-groups.js b/ui/app/components/job-page/parts/task-groups.js new file mode 100644 index 000000000..f5ce33757 --- /dev/null +++ b/ui/app/components/job-page/parts/task-groups.js @@ -0,0 +1,24 @@ +import Component from '@ember/component'; +import { computed } from '@ember/object'; +import { alias } from '@ember/object/computed'; +import Sortable from 'nomad-ui/mixins/sortable'; + +export default Component.extend(Sortable, { + job: null, + + classNames: ['boxed-section'], + + // Provide a value that is bound to a query param + sortProperty: null, + sortDescending: null, + + // Provide an action with access to the router + gotoTaskGroup() {}, + + taskGroups: computed('job.taskGroups.[]', function() { + return this.get('job.taskGroups') || []; + }), + + listToSort: alias('taskGroups'), + sortedTaskGroups: alias('listSorted'), +}); diff --git a/ui/app/components/job-page/periodic-child.js b/ui/app/components/job-page/periodic-child.js new file mode 100644 index 000000000..060627d93 --- /dev/null +++ b/ui/app/components/job-page/periodic-child.js @@ -0,0 +1,21 @@ +import AbstractJobPage from './abstract'; +import { computed } from '@ember/object'; + +export default AbstractJobPage.extend({ + breadcrumbs: computed('job.{name,id}', 'job.parent.{name,id}', function() { + const job = this.get('job'); + const parent = this.get('job.parent'); + + return [ + { label: 'Jobs', args: ['jobs'] }, + { + label: parent.get('name'), + args: ['jobs.job', parent], + }, + { + label: job.get('trimmedName'), + args: ['jobs.job', job], + }, + ]; + }), +}); diff --git a/ui/app/components/job-page/periodic.js b/ui/app/components/job-page/periodic.js new file mode 100644 index 000000000..705d95a2f --- /dev/null +++ b/ui/app/components/job-page/periodic.js @@ -0,0 +1,15 @@ +import AbstractJobPage from './abstract'; +import { inject as service } from '@ember/service'; + +export default AbstractJobPage.extend({ + store: service(), + actions: { + forceLaunch() { + this.get('job') + .forcePeriodic() + .then(() => { + this.get('store').findAll('job'); + }); + }, + }, +}); diff --git a/ui/app/components/job-page/service.js b/ui/app/components/job-page/service.js new file mode 100644 index 000000000..559b3c8b8 --- /dev/null +++ b/ui/app/components/job-page/service.js @@ -0,0 +1,3 @@ +import AbstractJobPage from './abstract'; + +export default AbstractJobPage.extend(); diff --git a/ui/app/controllers/jobs/index.js b/ui/app/controllers/jobs/index.js index d0d06e9ee..db1a561c0 100644 --- a/ui/app/controllers/jobs/index.js +++ b/ui/app/controllers/jobs/index.js @@ -1,5 +1,5 @@ import { inject as service } from '@ember/service'; -import { alias, filterBy } from '@ember/object/computed'; +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'; @@ -11,10 +11,6 @@ export default Controller.extend(Sortable, Searchable, { isForbidden: alias('jobsController.isForbidden'), - pendingJobs: filterBy('model', 'status', 'pending'), - runningJobs: filterBy('model', 'status', 'running'), - deadJobs: filterBy('model', 'status', 'dead'), - queryParams: { currentPage: 'page', searchTerm: 'search', @@ -30,16 +26,22 @@ export default Controller.extend(Sortable, Searchable, { searchProps: computed(() => ['id', 'name']), + /** + Filtered jobs are those that match the selected namespace and aren't children + of periodic or parameterized jobs. + */ filteredJobs: computed( 'model.[]', + 'model.@each.parent', 'system.activeNamespace', 'system.namespaces.length', function() { - if (this.get('system.namespaces.length')) { - return this.get('model').filterBy('namespace.id', this.get('system.activeNamespace.id')); - } else { - return this.get('model'); - } + const hasNamespaces = this.get('system.namespaces.length'); + const activeNamespace = this.get('system.activeNamespace.id'); + + return this.get('model') + .filter(job => !hasNamespaces || job.get('namespace.id') === activeNamespace) + .filter(job => !job.get('parent.content')); } ), diff --git a/ui/app/controllers/jobs/job/index.js b/ui/app/controllers/jobs/job/index.js index 97b97efb5..c5cb709a9 100644 --- a/ui/app/controllers/jobs/job/index.js +++ b/ui/app/controllers/jobs/job/index.js @@ -1,11 +1,9 @@ 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'; -export default Controller.extend(Sortable, WithNamespaceResetting, { +export default Controller.extend(WithNamespaceResetting, { system: service(), jobController: controller('jobs.job'), @@ -16,7 +14,6 @@ export default Controller.extend(Sortable, WithNamespaceResetting, { }, currentPage: 1, - pageSize: 10, sortProperty: 'name', sortDescending: false, @@ -24,20 +21,15 @@ export default Controller.extend(Sortable, WithNamespaceResetting, { breadcrumbs: alias('jobController.breadcrumbs'), job: alias('model'), - taskGroups: computed('model.taskGroups.[]', function() { - return this.get('model.taskGroups') || []; - }), - - listToSort: alias('taskGroups'), - sortedTaskGroups: alias('listSorted'), - - sortedEvaluations: computed('model.evaluations.@each.modifyIndex', function() { - return (this.get('model.evaluations') || []).sortBy('modifyIndex').reverse(); - }), - actions: { gotoTaskGroup(taskGroup) { this.transitionToRoute('jobs.job.task-group', taskGroup.get('job'), taskGroup); }, + + gotoJob(job) { + this.transitionToRoute('jobs.job', job, { + queryParams: { jobNamespace: job.get('namespace.name') }, + }); + }, }, }); diff --git a/ui/app/models/job.js b/ui/app/models/job.js index 511f89856..b77fc7a66 100644 --- a/ui/app/models/job.js +++ b/ui/app/models/job.js @@ -1,4 +1,4 @@ -import { collect, sum, bool, equal } from '@ember/object/computed'; +import { collect, sum, bool, equal, or } from '@ember/object/computed'; import { computed } from '@ember/object'; import Model from 'ember-data/model'; import attr from 'ember-data/attr'; @@ -6,6 +6,8 @@ import { belongsTo, hasMany } from 'ember-data/relationships'; import { fragmentArray } from 'ember-data-model-fragments/attributes'; import sumAggregation from '../utils/properties/sum-aggregation'; +const JOB_TYPES = ['service', 'batch', 'system']; + export default Model.extend({ region: attr('string'), name: attr('string'), @@ -19,9 +21,66 @@ export default Model.extend({ createIndex: attr('number'), modifyIndex: attr('number'), + // True when the job is the parent periodic or parameterized jobs + // Instances of periodic or parameterized jobs are false for both properties periodic: attr('boolean'), parameterized: attr('boolean'), + periodicDetails: attr(), + parameterizedDetails: attr(), + + hasChildren: or('periodic', 'parameterized'), + + parent: belongsTo('job', { inverse: 'children' }), + children: hasMany('job', { inverse: 'parent' }), + + // The parent job name is prepended to child launch job names + trimmedName: computed('name', 'parent', function() { + return this.get('parent.content') ? this.get('name').replace(/.+?\//, '') : this.get('name'); + }), + + // A composite of type and other job attributes to determine + // a better type descriptor for human interpretation rather + // than for scheduling. + displayType: computed('type', 'periodic', 'parameterized', function() { + if (this.get('periodic')) { + return 'periodic'; + } else if (this.get('parameterized')) { + return 'parameterized'; + } + return this.get('type'); + }), + + // A composite of type and other job attributes to determine + // type for templating rather than scheduling + templateType: computed( + 'type', + 'periodic', + 'parameterized', + 'parent.periodic', + 'parent.parameterized', + function() { + const type = this.get('type'); + + if (this.get('periodic')) { + return 'periodic'; + } else if (this.get('parameterized')) { + return 'parameterized'; + } else if (this.get('parent.periodic')) { + return 'periodic-child'; + } else if (this.get('parent.parameterized')) { + return 'parameterized-child'; + } else if (JOB_TYPES.includes(type)) { + // Guard against the API introducing a new type before the UI + // is prepared to handle it. + return this.get('type'); + } + + // A fail-safe in the event the API introduces a new type. + return 'service'; + } + ), + datacenters: attr(), taskGroups: fragmentArray('task-group', { defaultValue: () => [] }), taskGroupSummaries: fragmentArray('task-group-summary'), @@ -49,6 +108,12 @@ export default Model.extend({ runningChildren: attr('number'), deadChildren: attr('number'), + childrenList: collect('pendingChildren', 'runningChildren', 'deadChildren'), + + totalChildren: sum('childrenList'), + + version: attr('number'), + versions: hasMany('job-versions'), allocations: hasMany('allocations'), deployments: hasMany('deployments'), @@ -91,6 +156,10 @@ export default Model.extend({ return this.store.adapterFor('job').fetchRawDefinition(this); }, + forcePeriodic() { + return this.store.adapterFor('job').forcePeriodic(this); + }, + statusClass: computed('status', function() { const classMap = { pending: 'is-pending', @@ -100,4 +169,10 @@ export default Model.extend({ return classMap[this.get('status')] || 'is-dark'; }), + + payload: attr('string'), + decodedPayload: computed('payload', function() { + // Lazily decode the base64 encoded payload + return window.atob(this.get('payload') || ''); + }), }); diff --git a/ui/app/models/task-event.js b/ui/app/models/task-event.js index 3d6f464f3..86ff9e3c7 100644 --- a/ui/app/models/task-event.js +++ b/ui/app/models/task-event.js @@ -1,27 +1,6 @@ -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 displayProps = [ - 'message', - 'validationError', - 'setupError', - 'driverError', - 'downloadError', - 'killReason', - 'killTimeout', - 'killError', - 'exitCode', - 'signal', - 'startDelay', - 'restartReason', - 'failedSibling', - 'taskSignal', - 'taskSignalReason', - 'driverMessage', -]; export default Fragment.extend({ state: fragmentOwner(), @@ -45,103 +24,5 @@ export default Fragment.extend({ taskSignal: attr('string'), taskSignalReason: attr('string'), validationError: attr('string'), - vaultError: attr('string'), message: attr('string'), - failedSibling: attr('string'), - - displayMessage: computed(...displayProps, function() { - let desc = ''; - switch (this.get('type')) { - case 'Task Setup': - desc = this.get('message'); - break; - case 'Started': - desc = 'Task started by client'; - break; - case 'Received': - desc = 'Task received by client'; - break; - case 'Failed Validation': - desc = this.get('validationError') || 'Validation of task failed'; - break; - case 'Setup Failure': - desc = this.get('setupError') || 'Task setup failed'; - break; - case 'Driver Failure': - desc = this.get('driverError') || 'Failed to start task'; - break; - case 'Downloading Artifacts': - desc = 'Client is downloading artifacts'; - break; - case 'Failed Artifact Download': - desc = this.get('downloadError') || 'Failed to download artifacts'; - break; - case 'Killing': - desc = - this.get('killReason') || - (this.get('killTimeout') && - `Sent interrupt. Waiting ${this.get('killTimeout')} before force killing`); - break; - case 'Killed': - desc = this.get('killError') || 'Task successfully killed'; - break; - case 'Terminated': - var parts = [`Exit Code: ${this.get('exitCode')}`]; - if (this.get('signal')) { - parts.push(`Signal: ${this.get('signal')}`); - } - if (this.get('message')) { - parts.push(`Exit Message: ${this.get('message')}`); - } - desc = parts.join(', '); - break; - case 'Restarting': - var timerMessage = `Task restarting in ${moment - .duration(this.get('startDelay') / 1000000, 'ms') - .humanize()}`; - if (this.get('restartReason') && this.get('restartReason') !== 'Restart within policy') { - desc = `${this.get('restartReason')} - ${timerMessage}`; - } else { - desc = timerMessage; - } - break; - case 'Not Restarting': - desc = this.get('restartReason') || 'Task exceeded restart policy'; - break; - case 'Sibling Task Failed': - desc = this.get('failedSibling') - ? `Task's sibling ${this.get('failedSibling')} failed` - : "Task's sibling failed"; - break; - case 'Signaling': - var signal = this.get('taskSignal'); - var reason = this.get('taskSignalReason'); - - if (!signal && !reason) { - desc = 'Task being sent a signal'; - } else if (!signal) { - desc = reason; - } else if (!reason) { - desc = `Task being sent signal ${signal}`; - } else { - desc = `Task being sent signal ${signal}: ${reason}`; - } - - break; - case 'Restart Signaled': - desc = this.get('restartReason') || 'Task signaled to restart'; - break; - case 'Driver': - desc = this.get('driverMessage'); - break; - case 'Leader Task Dead': - desc = 'Leader Task in Group dead'; - break; - case 'Generic': - desc = this.get('message'); - break; - } - - return desc; - }), }); diff --git a/ui/app/serializers/job.js b/ui/app/serializers/job.js index e09c95cd8..df77e2f38 100644 --- a/ui/app/serializers/job.js +++ b/ui/app/serializers/job.js @@ -15,6 +15,25 @@ export default ApplicationSerializer.extend({ hash.PlainId = hash.ID; hash.ID = JSON.stringify([hash.ID, hash.NamespaceID || 'default']); + // ParentID comes in as "" instead of null + if (!hash.ParentID) { + hash.ParentID = null; + } else { + hash.ParentID = JSON.stringify([hash.ParentID, hash.NamespaceID || 'default']); + } + + // Periodic is a boolean on list and an object on single + if (hash.Periodic instanceof Object) { + hash.PeriodicDetails = hash.Periodic; + hash.Periodic = true; + } + + // Parameterized behaves like Periodic + if (hash.ParameterizedJob instanceof Object) { + hash.ParameterizedDetails = hash.ParameterizedJob; + hash.ParameterizedJob = true; + } + // Transform the map-based JobSummary object into an array-based // JobSummary fragment list hash.TaskGroupSummaries = Object.keys(get(hash, 'JobSummary.Summary') || {}).map(key => { diff --git a/ui/app/styles/components/cli-window.scss b/ui/app/styles/components/cli-window.scss index f8be6ff7a..0f78d1708 100644 --- a/ui/app/styles/components/cli-window.scss +++ b/ui/app/styles/components/cli-window.scss @@ -12,4 +12,8 @@ .is-light { color: $text; } + + &.is-elastic { + height: auto; + } } diff --git a/ui/app/styles/core/buttons.scss b/ui/app/styles/core/buttons.scss index 27d44f0e9..9a360f3c9 100644 --- a/ui/app/styles/core/buttons.scss +++ b/ui/app/styles/core/buttons.scss @@ -24,6 +24,10 @@ $button-box-shadow-standard: 0 2px 0 0 rgba($grey, 0.2); background: transparent; } + &.is-inline { + vertical-align: middle; + } + &.is-compact { padding: 0.25em 0.75em; margin: -0.25em -0.25em -0.25em 0; diff --git a/ui/app/templates/allocations/allocation/index.hbs b/ui/app/templates/allocations/allocation/index.hbs index 9778b2637..659329b65 100644 --- a/ui/app/templates/allocations/allocation/index.hbs +++ b/ui/app/templates/allocations/allocation/index.hbs @@ -50,8 +50,8 @@ {{row.model.state}} - {{#if row.model.events.lastObject.displayMessage}} - {{row.model.events.lastObject.displayMessage}} + {{#if row.model.events.lastObject.message}} + {{row.model.events.lastObject.message}} {{else}} No message {{/if}} diff --git a/ui/app/templates/allocations/allocation/task/index.hbs b/ui/app/templates/allocations/allocation/task/index.hbs index aebe8eeed..60a311bfa 100644 --- a/ui/app/templates/allocations/allocation/task/index.hbs +++ b/ui/app/templates/allocations/allocation/task/index.hbs @@ -83,8 +83,8 @@ {{moment-format row.model.time "MM/DD/YY HH:mm:ss"}} {{row.model.type}} - {{#if row.model.displayMessage}} - {{row.model.displayMessage}} + {{#if row.model.message}} + {{row.model.message}} {{else}} No message {{/if}} diff --git a/ui/app/templates/components/gutter-menu.hbs b/ui/app/templates/components/gutter-menu.hbs index 055e8e323..716a623fd 100644 --- a/ui/app/templates/components/gutter-menu.hbs +++ b/ui/app/templates/components/gutter-menu.hbs @@ -42,6 +42,6 @@ -
+
{{yield}}
diff --git a/ui/app/templates/components/job-page/batch.hbs b/ui/app/templates/components/job-page/batch.hbs new file mode 100644 index 000000000..74b7f5c28 --- /dev/null +++ b/ui/app/templates/components/job-page/batch.hbs @@ -0,0 +1,35 @@ +{{#global-header class="page-header"}} + {{#each breadcrumbs as |breadcrumb index|}} + + {{/each}} +{{/global-header}} +{{#job-page/parts/body job=job onNamespaceChange=onNamespaceChange}} +

+ {{job.name}} + {{job.status}} +

+ +
+
+ Type: {{job.type}} | + Priority: {{job.priority}} + {{#if (and job.namespace system.shouldShowNamespaces)}} + | Namespace: {{job.namespace.name}} + {{/if}} +
+
+ + {{job-page/parts/summary job=job}} + + {{job-page/parts/placement-failures job=job}} + + {{job-page/parts/task-groups + job=job + sortProperty=sortProperty + sortDescending=sortDescending + gotoTaskGroup=gotoTaskGroup}} + + {{job-page/parts/evaluations job=job}} +{{/job-page/parts/body}} diff --git a/ui/app/templates/components/job-page/parameterized-child.hbs b/ui/app/templates/components/job-page/parameterized-child.hbs new file mode 100644 index 000000000..b01ad400f --- /dev/null +++ b/ui/app/templates/components/job-page/parameterized-child.hbs @@ -0,0 +1,52 @@ +{{#global-header class="page-header"}} + {{#each breadcrumbs as |breadcrumb index|}} + + {{/each}} +{{/global-header}} +{{#job-page/parts/body job=job onNamespaceChange=onNamespaceChange}} +

+ {{job.trimmedName}} + {{job.status}} +

+ +
+
+ Type: {{job.type}} | + Priority: {{job.priority}} + + Parent: + {{#link-to "jobs.job" job.parent (query-params jobNamespace=job.parent.namespace.name)}} + {{job.parent.name}} + {{/link-to}} + + {{#if (and job.namespace system.shouldShowNamespaces)}} + | Namespace: {{job.namespace.name}} + {{/if}} +
+
+ + {{job-page/parts/summary job=job}} + + {{job-page/parts/placement-failures job=job}} + + {{job-page/parts/task-groups + job=job + sortProperty=sortProperty + sortDescending=sortDescending + gotoTaskGroup=gotoTaskGroup}} + + {{job-page/parts/evaluations job=job}} + +
+
Payload
+
+ {{#if payloadJSON}} + {{json-viewer json=payloadJSON}} + {{else}} +
{{payload}}
+ {{/if}} +
+
+{{/job-page/parts/body}} diff --git a/ui/app/templates/components/job-page/parameterized.hbs b/ui/app/templates/components/job-page/parameterized.hbs new file mode 100644 index 000000000..a8e3ed47f --- /dev/null +++ b/ui/app/templates/components/job-page/parameterized.hbs @@ -0,0 +1,32 @@ +{{#global-header class="page-header"}} + {{#each breadcrumbs as |breadcrumb index|}} + + {{/each}} +{{/global-header}} +{{#job-page/parts/body job=job onNamespaceChange=onNamespaceChange}} +

+ {{job.name}} + {{job.status}} + Parameterized +

+ +
+
+ Version: {{job.version}} | + Priority: {{job.priority}} + {{#if (and job.namespace system.shouldShowNamespaces)}} + | Namespace: {{job.namespace.name}} + {{/if}} +
+
+ + {{job-page/parts/summary job=job}} + {{job-page/parts/children + job=job + sortProperty=sortProperty + sortDescending=sortDescending + currentPage=currentPage + gotoJob=gotoJob}} +{{/job-page/parts/body}} diff --git a/ui/app/templates/components/job-page/parts/body.hbs b/ui/app/templates/components/job-page/parts/body.hbs new file mode 100644 index 000000000..12c339ce4 --- /dev/null +++ b/ui/app/templates/components/job-page/parts/body.hbs @@ -0,0 +1,6 @@ +{{#gutter-menu class="page-body" onNamespaceChange=onNamespaceChange}} + {{partial "jobs/job/subnav"}} +
+ {{yield}} +
+{{/gutter-menu}} diff --git a/ui/app/templates/components/job-page/parts/children.hbs b/ui/app/templates/components/job-page/parts/children.hbs new file mode 100644 index 000000000..944168f1b --- /dev/null +++ b/ui/app/templates/components/job-page/parts/children.hbs @@ -0,0 +1,42 @@ +
+ Job Launches +
+
+ {{#list-pagination + source=sortedChildren + size=pageSize + page=currentPage as |p|}} + {{#list-table + source=p.list + sortProperty=sortProperty + sortDescending=sortDescending + class="with-foot" as |t|}} + {{#t.head}} + {{#t.sort-by prop="name"}}Name{{/t.sort-by}} + {{#t.sort-by prop="status"}}Status{{/t.sort-by}} + {{#t.sort-by prop="type"}}Type{{/t.sort-by}} + {{#t.sort-by prop="priority"}}Priority{{/t.sort-by}} + Groups + Summary + {{/t.head}} + {{#t.body key="model.id" as |row|}} + {{job-row data-test-job-row job=row.model onClick=(action gotoJob row.model)}} + {{/t.body}} + {{/list-table}} +
+ +
+ {{else}} +
+

No Job Launches

+

No remaining living job launches.

+
+ {{/list-pagination}} +
diff --git a/ui/app/templates/components/job-page/parts/evaluations.hbs b/ui/app/templates/components/job-page/parts/evaluations.hbs new file mode 100644 index 000000000..f49a6f10b --- /dev/null +++ b/ui/app/templates/components/job-page/parts/evaluations.hbs @@ -0,0 +1,38 @@ +
+ Evaluations +
+
+ {{#if sortedEvaluations.length}} + {{#list-table source=sortedEvaluations as |t|}} + {{#t.head}} + ID + Priority + Triggered By + Status + Placement Failures + {{/t.head}} + {{#t.body as |row|}} + + {{row.model.shortId}} + {{row.model.priority}} + {{row.model.triggeredBy}} + {{row.model.status}} + + {{#if (eq row.model.status "blocked")}} + N/A - In Progress + {{else if row.model.hasPlacementFailures}} + True + {{else}} + False + {{/if}} + + + {{/t.body}} + {{/list-table}} + {{else}} +
+

No Evaluations

+

This is most likely due to garbage collection.

+
+ {{/if}} +
diff --git a/ui/app/templates/components/job-page/parts/placement-failures.hbs b/ui/app/templates/components/job-page/parts/placement-failures.hbs new file mode 100644 index 000000000..f8bf078d0 --- /dev/null +++ b/ui/app/templates/components/job-page/parts/placement-failures.hbs @@ -0,0 +1,12 @@ +{{#if job.hasPlacementFailures}} +
+
+ Placement Failures +
+
+ {{#each job.taskGroups as |taskGroup|}} + {{placement-failure taskGroup=taskGroup}} + {{/each}} +
+
+{{/if}} diff --git a/ui/app/templates/components/job-page/parts/running-deployment.hbs b/ui/app/templates/components/job-page/parts/running-deployment.hbs new file mode 100644 index 000000000..c42bb866d --- /dev/null +++ b/ui/app/templates/components/job-page/parts/running-deployment.hbs @@ -0,0 +1,33 @@ +{{#if job.runningDeployment}} +
+
+
+ Active Deployment + {{job.runningDeployment.shortId}} + {{#if job.runningDeployment.version.submitTime}} + {{moment-from-now job.runningDeployment.version.submitTime}} + {{/if}} +
+
+ Running + {{#if job.runningDeployment.requiresPromotion}} + Deployment is running but requires promotion + {{/if}} +
+
+
+ {{#job-deployment-details deployment=job.runningDeployment as |d|}} + {{d.metrics}} + {{#if isShowingDeploymentDetails}} + {{d.taskGroups}} + {{d.allocations}} + {{/if}} + {{/job-deployment-details}} +
+ +
+{{/if}} diff --git a/ui/app/templates/components/job-page/parts/summary.hbs b/ui/app/templates/components/job-page/parts/summary.hbs new file mode 100644 index 000000000..57e2dd25e --- /dev/null +++ b/ui/app/templates/components/job-page/parts/summary.hbs @@ -0,0 +1,27 @@ +
+
+ {{#if job.hasChildren}} + Children Status {{job.totalChildren}} + {{else}} + Allocation Status {{job.totalAllocs}} + {{/if}} +
+
+
+ {{#component (if job.hasChildren "children-status-bar" "allocation-status-bar") + allocationContainer=job + job=job + class="split-view" as |chart|}} +
    + {{#each chart.data as |datum index|}} +
  1. + + {{datum.value}} + + {{datum.label}} + +
  2. + {{/each}} +
+ {{/component}} +
diff --git a/ui/app/templates/components/job-page/parts/task-groups.hbs b/ui/app/templates/components/job-page/parts/task-groups.hbs new file mode 100644 index 000000000..d6fbf5942 --- /dev/null +++ b/ui/app/templates/components/job-page/parts/task-groups.hbs @@ -0,0 +1,25 @@ +
+
+ Task Groups +
+
+ {{#list-table + source=sortedTaskGroups + sortProperty=sortProperty + sortDescending=sortDescending as |t|}} + {{#t.head}} + {{#t.sort-by prop="name"}}Name{{/t.sort-by}} + {{#t.sort-by prop="count"}}Count{{/t.sort-by}} + {{#t.sort-by prop="queuedOrStartingAllocs" class="is-3"}}Allocation Status{{/t.sort-by}} + {{#t.sort-by prop="reservedCPU"}}Reserved CPU{{/t.sort-by}} + {{#t.sort-by prop="reservedMemory"}}Reserved Memory{{/t.sort-by}} + {{#t.sort-by prop="reservedEphemeralDisk"}}Reserved Disk{{/t.sort-by}} + {{/t.head}} + {{#t.body as |row|}} + {{task-group-row data-test-task-group + taskGroup=row.model + onClick=(action gotoTaskGroup row.model)}} + {{/t.body}} + {{/list-table}} +
+
diff --git a/ui/app/templates/components/job-page/periodic-child.hbs b/ui/app/templates/components/job-page/periodic-child.hbs new file mode 100644 index 000000000..e39aed637 --- /dev/null +++ b/ui/app/templates/components/job-page/periodic-child.hbs @@ -0,0 +1,41 @@ +{{#global-header class="page-header"}} + {{#each breadcrumbs as |breadcrumb index|}} + + {{/each}} +{{/global-header}} +{{#job-page/parts/body job=job onNamespaceChange=onNamespaceChange}} +

+ {{job.trimmedName}} + {{job.status}} +

+ +
+
+ Type: {{job.type}} | + Priority: {{job.priority}} + + Parent: + {{#link-to "jobs.job" job.parent (query-params jobNamespace=job.parent.namespace.name)}} + {{job.parent.name}} + {{/link-to}} + + {{#if (and job.namespace system.shouldShowNamespaces)}} + | Namespace: {{job.namespace.name}} + {{/if}} +
+
+ + {{job-page/parts/summary job=job}} + + {{job-page/parts/placement-failures job=job}} + + {{job-page/parts/task-groups + job=job + sortProperty=sortProperty + sortDescending=sortDescending + gotoTaskGroup=gotoTaskGroup}} + + {{job-page/parts/evaluations job=job}} +{{/job-page/parts/body}} diff --git a/ui/app/templates/components/job-page/periodic.hbs b/ui/app/templates/components/job-page/periodic.hbs new file mode 100644 index 000000000..6a815b332 --- /dev/null +++ b/ui/app/templates/components/job-page/periodic.hbs @@ -0,0 +1,34 @@ +{{#global-header class="page-header"}} + {{#each breadcrumbs as |breadcrumb index|}} + + {{/each}} +{{/global-header}} +{{#job-page/parts/body job=job onNamespaceChange=onNamespaceChange}} +

+ {{job.name}} + {{job.status}} + periodic + +

+ +
+
+ Version: {{job.version}} | + Priority: {{job.priority}} + {{#if (and job.namespace system.shouldShowNamespaces)}} + | Namespace: {{job.namespace.name}} + {{/if}} + | Cron: {{job.periodicDetails.Spec}} +
+
+ + {{job-page/parts/summary job=job}} + {{job-page/parts/children + job=job + sortProperty=sortProperty + sortDescending=sortDescending + currentPage=currentPage + gotoJob=gotoJob}} +{{/job-page/parts/body}} diff --git a/ui/app/templates/components/job-page/service.hbs b/ui/app/templates/components/job-page/service.hbs new file mode 100644 index 000000000..47343c49a --- /dev/null +++ b/ui/app/templates/components/job-page/service.hbs @@ -0,0 +1,37 @@ +{{#global-header class="page-header"}} + {{#each breadcrumbs as |breadcrumb index|}} + + {{/each}} +{{/global-header}} +{{#job-page/parts/body job=job onNamespaceChange=onNamespaceChange}} +

+ {{job.name}} + {{job.status}} +

+ +
+
+ Type: {{job.type}} | + Priority: {{job.priority}} + {{#if (and job.namespace system.shouldShowNamespaces)}} + | Namespace: {{job.namespace.name}} + {{/if}} +
+
+ + {{job-page/parts/summary job=job}} + + {{job-page/parts/placement-failures job=job}} + + {{job-page/parts/running-deployment job=job}} + + {{job-page/parts/task-groups + job=job + sortProperty=sortProperty + sortDescending=sortDescending + gotoTaskGroup=gotoTaskGroup}} + + {{job-page/parts/evaluations job=job}} +{{/job-page/parts/body}} diff --git a/ui/app/templates/components/job-page/system.hbs b/ui/app/templates/components/job-page/system.hbs new file mode 100644 index 000000000..47343c49a --- /dev/null +++ b/ui/app/templates/components/job-page/system.hbs @@ -0,0 +1,37 @@ +{{#global-header class="page-header"}} + {{#each breadcrumbs as |breadcrumb index|}} + + {{/each}} +{{/global-header}} +{{#job-page/parts/body job=job onNamespaceChange=onNamespaceChange}} +

+ {{job.name}} + {{job.status}} +

+ +
+
+ Type: {{job.type}} | + Priority: {{job.priority}} + {{#if (and job.namespace system.shouldShowNamespaces)}} + | Namespace: {{job.namespace.name}} + {{/if}} +
+
+ + {{job-page/parts/summary job=job}} + + {{job-page/parts/placement-failures job=job}} + + {{job-page/parts/running-deployment job=job}} + + {{job-page/parts/task-groups + job=job + sortProperty=sortProperty + sortDescending=sortDescending + gotoTaskGroup=gotoTaskGroup}} + + {{job-page/parts/evaluations job=job}} +{{/job-page/parts/body}} diff --git a/ui/app/templates/components/job-row.hbs b/ui/app/templates/components/job-row.hbs index 54032c664..f64d05889 100644 --- a/ui/app/templates/components/job-row.hbs +++ b/ui/app/templates/components/job-row.hbs @@ -2,7 +2,7 @@ {{job.status}} -{{job.type}} +{{job.displayType}} {{job.priority}} {{#if job.isReloading}} @@ -12,5 +12,11 @@ {{/if}} -
{{allocation-status-bar allocationContainer=job isNarrow=true}}
+
+ {{#if job.hasChildren}} + {{children-status-bar job=job isNarrow=true}} + {{else}} + {{allocation-status-bar allocationContainer=job isNarrow=true}} + {{/if}} +
diff --git a/ui/app/templates/jobs/index.hbs b/ui/app/templates/jobs/index.hbs index 9c8c93888..34e24c33a 100644 --- a/ui/app/templates/jobs/index.hbs +++ b/ui/app/templates/jobs/index.hbs @@ -28,7 +28,7 @@ {{#t.sort-by prop="type"}}Type{{/t.sort-by}} {{#t.sort-by prop="priority"}}Priority{{/t.sort-by}} Groups - Allocation Status + Summary {{/t.head}} {{#t.body key="model.id" as |row|}} {{job-row data-test-job-row job=row.model onClick=(action "gotoJob" row.model)}} diff --git a/ui/app/templates/jobs/job/index.hbs b/ui/app/templates/jobs/job/index.hbs index bc2e6b7ce..7d81851b2 100644 --- a/ui/app/templates/jobs/job/index.hbs +++ b/ui/app/templates/jobs/job/index.hbs @@ -1,162 +1,8 @@ -{{#global-header class="page-header"}} - {{#each breadcrumbs as |breadcrumb index|}} - - {{/each}} -{{/global-header}} -{{#gutter-menu class="page-body" onNamespaceChange=(action "gotoJobs")}} - {{partial "jobs/job/subnav"}} -
-

- {{model.name}} - {{model.status}} - {{#if model.periodic}} - periodic - {{else if model.parameterized}} - parameterized - {{/if}} -

- -
-
- Type: {{model.type}} | - Priority: {{model.priority}} - {{#if (and model.namespace system.shouldShowNamespaces)}} - | Namespace: {{model.namespace.name}} - {{/if}} -
-
- -
-
-
Allocation Status {{taskGroups.length}}
-
-
- {{#allocation-status-bar allocationContainer=model class="split-view" as |chart|}} -
    - {{#each chart.data as |datum index|}} -
  1. - - {{datum.value}} - - {{datum.label}} - -
  2. - {{/each}} -
- {{/allocation-status-bar}} -
-
- - {{#if model.hasPlacementFailures}} -
-
- Placement Failures -
-
- {{#each model.taskGroups as |taskGroup|}} - {{placement-failure taskGroup=taskGroup}} - {{/each}} -
-
- {{/if}} - - {{#if model.runningDeployment}} -
-
-
- Active Deployment - {{model.runningDeployment.shortId}} - {{#if model.runningDeployment.version.submitTime}} - {{moment-from-now model.runningDeployment.version.submitTime}} - {{/if}} -
-
- Running - {{#if model.runningDeployment.requiresPromotion}} - Deployment is running but requires promotion - {{/if}} -
-
-
- {{#job-deployment-details deployment=model.runningDeployment as |d|}} - {{d.metrics}} - {{#if isShowingDeploymentDetails}} - {{d.taskGroups}} - {{d.allocations}} - {{/if}} - {{/job-deployment-details}} -
- -
- {{/if}} - -
-
- Task Groups -
-
- {{#list-pagination - source=sortedTaskGroups - sortProperty=sortProperty - sortDescending=sortDescending as |p|}} - {{#list-table - source=p.list - sortProperty=sortProperty - sortDescending=sortDescending as |t|}} - {{#t.head}} - {{#t.sort-by prop="name"}}Name{{/t.sort-by}} - {{#t.sort-by prop="count"}}Count{{/t.sort-by}} - {{#t.sort-by prop="queuedOrStartingAllocs" class="is-3"}}Allocation Status{{/t.sort-by}} - {{#t.sort-by prop="reservedCPU"}}Reserved CPU{{/t.sort-by}} - {{#t.sort-by prop="reservedMemory"}}Reserved Memory{{/t.sort-by}} - {{#t.sort-by prop="reservedEphemeralDisk"}}Reserved Disk{{/t.sort-by}} - {{/t.head}} - {{#t.body as |row|}} - {{task-group-row data-test-task-group taskGroup=row.model onClick=(action "gotoTaskGroup" row.model)}} - {{/t.body}} - {{/list-table}} - {{/list-pagination}} -
-
- -
-
- Evaluations -
-
- {{#list-table source=sortedEvaluations as |t|}} - {{#t.head}} - ID - Priority - Triggered By - Status - Placement Failures - {{/t.head}} - {{#t.body as |row|}} - - {{row.model.shortId}} - {{row.model.priority}} - {{row.model.triggeredBy}} - {{row.model.status}} - - {{#if (eq row.model.status "blocked")}} - N/A - In Progress - {{else if row.model.hasPlacementFailures}} - True - {{else}} - False - {{/if}} - - - {{/t.body}} - {{/list-table}} -
-
-
-{{/gutter-menu}} +{{component (concat "job-page/" model.templateType) + job=model + sortProperty=sortProperty + sortDescending=sortDescending + currentPage=currentPage + onNamespaceChange=(action "gotoJobs") + gotoJob=(action "gotoJob") + gotoTaskGroup=(action "gotoTaskGroup")}} diff --git a/ui/app/templates/jobs/job/subnav.hbs b/ui/app/templates/jobs/job/subnav.hbs index cfbdeb672..e4ef97f80 100644 --- a/ui/app/templates/jobs/job/subnav.hbs +++ b/ui/app/templates/jobs/job/subnav.hbs @@ -1,4 +1,4 @@ -
+
  • {{#link-to "jobs.job.index" job activeClass="is-active"}}Overview{{/link-to}}
  • {{#link-to "jobs.job.definition" job activeClass="is-active"}}Definition{{/link-to}}
  • diff --git a/ui/app/templates/jobs/job/task-group.hbs b/ui/app/templates/jobs/job/task-group.hbs index 90928f830..2934d2c1a 100644 --- a/ui/app/templates/jobs/job/task-group.hbs +++ b/ui/app/templates/jobs/job/task-group.hbs @@ -100,12 +100,21 @@
{{else}} -
-
-

No Matches

-

No allocations match the term {{searchTerm}}

+ {{#if allocations.length}} +
+
+

No Matches

+

No allocations match the term {{searchTerm}}

+
-
+ {{else}} +
+
+

No Allocations

+

No allocations have been placed.

+
+
+ {{/if}} {{/list-pagination}}
diff --git a/ui/mirage/config.js b/ui/mirage/config.js index b32c70b7c..c17c7d3e5 100644 --- a/ui/mirage/config.js +++ b/ui/mirage/config.js @@ -11,6 +11,7 @@ export function findLeader(schema) { } export default function() { + const server = this; this.timing = 0; // delay for each request, automatically set to 0 during testing this.namespace = 'v1'; @@ -58,6 +59,22 @@ export default function() { return this.serialize(deployments.where({ jobId: params.id })); }); + this.post('/job/:id/periodic/force', function(schema, { params }) { + // Create the child job + const parent = schema.jobs.find(params.id); + + // Use the server instead of the schema to leverage the job factory + server.create('job', 'periodicChild', { + parentId: parent.id, + namespaceId: parent.namespaceId, + namespace: parent.namespace, + createAllocations: parent.createAllocations, + }); + + // Return bogus, since the response is normally just eval information + return new Response(200, {}, '{}'); + }); + this.get('/deployment/:id'); this.get('/job/:id/evaluations', function({ evaluations }, { params }) { diff --git a/ui/mirage/factories/allocation.js b/ui/mirage/factories/allocation.js index 161f138e6..ecadc1d0e 100644 --- a/ui/mirage/factories/allocation.js +++ b/ui/mirage/factories/allocation.js @@ -18,9 +18,6 @@ export default Factory.extend({ clientStatus: faker.list.random(...CLIENT_STATUSES), desiredStatus: faker.list.random(...DESIRED_STATUSES), - // Meta property for hinting at task events - useMessagePassthru: false, - withTaskWithPorts: trait({ afterCreate(allocation, server) { const taskGroup = server.db.taskGroups.findBy({ name: allocation.taskGroup }); @@ -79,7 +76,6 @@ export default Factory.extend({ server.create('task-state', { allocation, name: server.db.tasks.find(id).name, - useMessagePassthru: allocation.useMessagePassthru, }) ); diff --git a/ui/mirage/factories/job-summary.js b/ui/mirage/factories/job-summary.js index c766b8b22..c76d39e9f 100644 --- a/ui/mirage/factories/job-summary.js +++ b/ui/mirage/factories/job-summary.js @@ -1,4 +1,4 @@ -import { Factory, faker } from 'ember-cli-mirage'; +import { Factory, faker, trait } from 'ember-cli-mirage'; export default Factory.extend({ // Hidden property used to compute the Summary hash @@ -6,17 +6,27 @@ export default Factory.extend({ JobID: '', - Summary: function() { - return this.groupNames.reduce((summary, group) => { - summary[group] = { - Queued: faker.random.number(10), - Complete: faker.random.number(10), - Failed: faker.random.number(10), - Running: faker.random.number(10), - Starting: faker.random.number(10), - Lost: faker.random.number(10), - }; - return summary; - }, {}); - }, + withSummary: trait({ + Summary: function() { + return this.groupNames.reduce((summary, group) => { + summary[group] = { + Queued: faker.random.number(10), + Complete: faker.random.number(10), + Failed: faker.random.number(10), + Running: faker.random.number(10), + Starting: faker.random.number(10), + Lost: faker.random.number(10), + }; + return summary; + }, {}); + }, + }), + + withChildren: trait({ + Children: () => ({ + Pending: faker.random.number(10), + Running: faker.random.number(10), + Dead: faker.random.number(10), + }), + }), }); diff --git a/ui/mirage/factories/job.js b/ui/mirage/factories/job.js index b18d16d71..fe97f3573 100644 --- a/ui/mirage/factories/job.js +++ b/ui/mirage/factories/job.js @@ -1,4 +1,4 @@ -import { Factory, faker } from 'ember-cli-mirage'; +import { Factory, faker, trait } from 'ember-cli-mirage'; import { provide, provider, pickOne } from '../utils'; import { DATACENTERS } from '../common'; @@ -22,10 +22,48 @@ export default Factory.extend({ faker.list.random(...DATACENTERS) ), - periodic: () => Math.random() > 0.5, - parameterized() { - return !this.periodic; - }, + childrenCount: () => faker.random.number({ min: 1, max: 5 }), + + periodic: trait({ + type: 'batch', + periodic: true, + // periodic details object + // serializer update for bool vs details object + periodicDetails: () => ({ + Enabled: true, + ProhibitOverlap: true, + Spec: '*/5 * * * * *', + SpecType: 'cron', + TimeZone: 'UTC', + }), + }), + + parameterized: trait({ + type: 'batch', + parameterized: true, + // parameterized details object + // serializer update for bool vs details object + parameterizedDetails: () => ({ + MetaOptional: null, + MetaRequired: null, + Payload: Math.random() > 0.5 ? 'required' : null, + }), + }), + + periodicChild: trait({ + // Periodic children need a parent job, + // It is the Periodic job's responsibility to create + // periodicChild jobs and provide a parent job. + type: 'batch', + }), + + parameterizedChild: trait({ + // Parameterized children need a parent job, + // It is the Parameterized job's responsibility to create + // parameterizedChild jobs and provide a parent job. + type: 'batch', + payload: window.btoa(faker.lorem.sentence()), + }), createIndex: i => i, modifyIndex: () => faker.random.number({ min: 10, max: 2000 }), @@ -70,7 +108,8 @@ export default Factory.extend({ }); } - const jobSummary = server.create('job-summary', { + const hasChildren = job.periodic || job.parameterized; + const jobSummary = server.create('job-summary', hasChildren ? 'withChildren' : 'withSummary', { groupNames: groups.mapBy('name'), job, }); @@ -102,5 +141,25 @@ export default Factory.extend({ modifyIndex: 4000, }); } + + if (job.periodic) { + // Create periodicChild jobs + server.createList('job', job.childrenCount, 'periodicChild', { + parentId: job.id, + namespaceId: job.namespaceId, + namespace: job.namespace, + createAllocations: job.createAllocations, + }); + } + + if (job.parameterized) { + // Create parameterizedChild jobs + server.createList('job', job.childrenCount, 'parameterizedChild', { + parentId: job.id, + namespaceId: job.namespaceId, + namespace: job.namespace, + createAllocations: job.createAllocations, + }); + } }, }); diff --git a/ui/mirage/factories/task-event.js b/ui/mirage/factories/task-event.js index e922894f4..ba64b003c 100644 --- a/ui/mirage/factories/task-event.js +++ b/ui/mirage/factories/task-event.js @@ -7,12 +7,6 @@ const STATES = provide(10, faker.system.fileExt.bind(faker.system)); export default Factory.extend({ type: faker.list.random(...STATES), - // Message is a function of type, and this type uses the vanilla - // message property. - messagePassthru: trait({ - type: 'Task Setup', - }), - signal: () => '', exitCode: () => null, time: () => faker.date.past(2 / 365, REF_TIME) * 1000000, @@ -26,6 +20,5 @@ export default Factory.extend({ setupError: () => '', taskSignalReason: () => '', validationError: () => '', - vaultError: () => '', message: () => faker.lorem.sentence(), }); diff --git a/ui/mirage/factories/task-state.js b/ui/mirage/factories/task-state.js index bbcf28a02..c0e274289 100644 --- a/ui/mirage/factories/task-state.js +++ b/ui/mirage/factories/task-state.js @@ -14,13 +14,11 @@ export default Factory.extend({ return new Date(this.startedAt + Math.random(1000 * 60 * 3) + 50); }, - useMessagePassthru: false, - afterCreate(state, server) { const props = [ 'task-event', faker.random.number({ min: 1, max: 10 }), - state.useMessagePassthru && 'messagePassthru', + false, { taskStateId: state.id, }, diff --git a/ui/package.json b/ui/package.json index 914d94ad2..7866bcc6b 100644 --- a/ui/package.json +++ b/ui/package.json @@ -14,11 +14,11 @@ "precommit": "lint-staged" }, "lint-staged": { - "ui/{app,tests,config,lib,mirage}/**/*.js": [ + "{app,tests,config,lib,mirage}/**/*.js": [ "prettier --write", "git add" ], - "ui/app/styles/**/*.*": [ + "app/styles/**/*.*": [ "prettier --write", "git add" ] diff --git a/ui/tests/acceptance/allocation-detail-test.js b/ui/tests/acceptance/allocation-detail-test.js index de651ca10..a660c57a9 100644 --- a/ui/tests/acceptance/allocation-detail-test.js +++ b/ui/tests/acceptance/allocation-detail-test.js @@ -14,17 +14,13 @@ moduleForAcceptance('Acceptance | allocation detail', { node = server.create('node'); job = server.create('job', { groupCount: 0 }); - allocation = server.create('allocation', 'withTaskWithPorts', { - useMessagePassthru: true, - }); + allocation = server.create('allocation', 'withTaskWithPorts'); visit(`/allocations/${allocation.id}`); }, }); -test('/allocation/:id should name the allocation and link to the corresponding job and node', function( - assert -) { +test('/allocation/:id should name the allocation and link to the corresponding job and node', function(assert) { assert.ok( find('[data-test-title]').textContent.includes(allocation.name), 'Allocation name is in the heading' @@ -125,9 +121,7 @@ test('each task row should list high-level information for the task', function(a }); }); -test('when the allocation is not found, an error message is shown, but the URL persists', function( - assert -) { +test('when the allocation is not found, an error message is shown, but the URL persists', function(assert) { visit('/allocations/not-a-real-allocation'); andThen(() => { diff --git a/ui/tests/acceptance/job-detail-test.js b/ui/tests/acceptance/job-detail-test.js index a1df252ec..be2593488 100644 --- a/ui/tests/acceptance/job-detail-test.js +++ b/ui/tests/acceptance/job-detail-test.js @@ -1,361 +1,38 @@ -import { get } from '@ember/object'; import { click, findAll, currentURL, find, visit } from 'ember-native-dom-helpers'; -import moment from 'moment'; import { test } from 'qunit'; import moduleForAcceptance from 'nomad-ui/tests/helpers/module-for-acceptance'; +import moduleForJob from 'nomad-ui/tests/helpers/module-for-job'; -const sum = (list, key) => list.reduce((sum, item) => sum + get(item, key), 0); +moduleForJob('Acceptance | job detail (batch)', () => server.create('job', { type: 'batch' })); +moduleForJob('Acceptance | job detail (system)', () => server.create('job', { type: 'system' })); +moduleForJob('Acceptance | job detail (periodic)', () => server.create('job', 'periodic')); -let job; +moduleForJob('Acceptance | job detail (parameterized)', () => + server.create('job', 'parameterized') +); -moduleForAcceptance('Acceptance | job detail', { - beforeEach() { - server.create('node'); - job = server.create('job', { type: 'service' }); - visit(`/jobs/${job.id}`); +moduleForJob('Acceptance | job detail (periodic child)', () => { + const parent = server.create('job', 'periodic'); + return server.db.jobs.where({ parentId: parent.id })[0]; +}); + +moduleForJob('Acceptance | job detail (parameterized child)', () => { + const parent = server.create('job', 'parameterized'); + return server.db.jobs.where({ parentId: parent.id })[0]; +}); + +moduleForJob('Acceptance | job detail (service)', () => server.create('job', { type: 'service' }), { + 'the subnav links to deployment': (job, assert) => { + click(find('[data-test-tab="deployments"] a')); + andThen(() => { + assert.equal(currentURL(), `/jobs/${job.id}/deployments`); + }); }, }); -test('visiting /jobs/:job_id', function(assert) { - assert.equal(currentURL(), `/jobs/${job.id}`); -}); +let job; -test('breadcrumbs includes job name and link back to the jobs list', function(assert) { - assert.equal( - find('[data-test-breadcrumb="Jobs"]').textContent, - 'Jobs', - 'First breadcrumb says jobs' - ); - assert.equal( - find(`[data-test-breadcrumb="${job.name}"]`).textContent, - job.name, - 'Second breadcrumb says the job name' - ); - - click(find('[data-test-breadcrumb="Jobs"]')); - andThen(() => { - assert.equal(currentURL(), '/jobs', 'First breadcrumb links back to jobs'); - }); -}); - -test('the subnav includes links to definition, versions, and deployments when type = service', function( - assert -) { - const subnavLabels = findAll('[data-test-tab]').map(anchor => anchor.textContent); - assert.ok(subnavLabels.some(label => label === 'Definition'), 'Definition link'); - assert.ok(subnavLabels.some(label => label === 'Versions'), 'Versions link'); - assert.ok(subnavLabels.some(label => label === 'Deployments'), 'Deployments link'); -}); - -test('the subnav includes links to definition and versions when type != service', function(assert) { - job = server.create('job', { type: 'batch' }); - visit(`/jobs/${job.id}`); - - andThen(() => { - const subnavLabels = findAll('[data-test-tab]').map(anchor => anchor.textContent); - assert.ok(subnavLabels.some(label => label === 'Definition'), 'Definition link'); - assert.ok(subnavLabels.some(label => label === 'Versions'), 'Versions link'); - assert.notOk(subnavLabels.some(label => label === 'Deployments'), 'Deployments link'); - }); -}); - -test('the job detail page should contain basic information about the job', function(assert) { - assert.ok(find('[data-test-job-status]').textContent.includes(job.status), 'Status'); - assert.ok(find('[data-test-job-stat="type"]').textContent.includes(job.type), 'Type'); - assert.ok(find('[data-test-job-stat="priority"]').textContent.includes(job.priority), 'Priority'); - assert.notOk(find('[data-test-job-stat="namespace"]'), 'Namespace is not included'); -}); - -test('the job detail page should list all task groups', function(assert) { - assert.equal( - findAll('[data-test-task-group]').length, - server.db.taskGroups.where({ jobId: job.id }).length - ); -}); - -test('each row in the task group table should show basic information about the task group', function( - assert -) { - const taskGroup = job.taskGroupIds.map(id => server.db.taskGroups.find(id)).sortBy('name')[0]; - const taskGroupRow = find('[data-test-task-group]'); - const tasks = server.db.tasks.where({ taskGroupId: taskGroup.id }); - const sum = (list, key) => list.reduce((sum, item) => sum + get(item, key), 0); - - assert.equal( - taskGroupRow.querySelector('[data-test-task-group-name]').textContent.trim(), - taskGroup.name, - 'Name' - ); - assert.equal( - taskGroupRow.querySelector('[data-test-task-group-count]').textContent.trim(), - taskGroup.count, - 'Count' - ); - assert.equal( - taskGroupRow.querySelector('[data-test-task-group-cpu]').textContent.trim(), - `${sum(tasks, 'Resources.CPU')} MHz`, - 'Reserved CPU' - ); - assert.equal( - taskGroupRow.querySelector('[data-test-task-group-mem]').textContent.trim(), - `${sum(tasks, 'Resources.MemoryMB')} MiB`, - 'Reserved Memory' - ); - assert.equal( - taskGroupRow.querySelector('[data-test-task-group-disk]').textContent.trim(), - `${taskGroup.ephemeralDisk.SizeMB} MiB`, - 'Reserved Disk' - ); -}); - -test('the allocations diagram lists all allocation status figures', function(assert) { - const jobSummary = server.db.jobSummaries.findBy({ jobId: job.id }); - const statusCounts = Object.keys(jobSummary.Summary).reduce( - (counts, key) => { - const group = jobSummary.Summary[key]; - counts.queued += group.Queued; - counts.starting += group.Starting; - counts.running += group.Running; - counts.complete += group.Complete; - counts.failed += group.Failed; - counts.lost += group.Lost; - return counts; - }, - { queued: 0, starting: 0, running: 0, complete: 0, failed: 0, lost: 0 } - ); - - assert.equal( - find('[data-test-legend-value="queued"]').textContent, - statusCounts.queued, - `${statusCounts.queued} are queued` - ); - - assert.equal( - find('[data-test-legend-value="starting"]').textContent, - statusCounts.starting, - `${statusCounts.starting} are starting` - ); - - assert.equal( - find('[data-test-legend-value="running"]').textContent, - statusCounts.running, - `${statusCounts.running} are running` - ); - - assert.equal( - find('[data-test-legend-value="complete"]').textContent, - statusCounts.complete, - `${statusCounts.complete} are complete` - ); - - assert.equal( - find('[data-test-legend-value="failed"]').textContent, - statusCounts.failed, - `${statusCounts.failed} are failed` - ); - - assert.equal( - find('[data-test-legend-value="lost"]').textContent, - statusCounts.lost, - `${statusCounts.lost} are lost` - ); -}); - -test('there is no active deployment section when the job has no active deployment', function( - assert -) { - // TODO: it would be better to not visit two different job pages in one test, but this - // way is much more convenient. - job = server.create('job', { noActiveDeployment: true, type: 'service' }); - visit(`/jobs/${job.id}`); - - andThen(() => { - assert.notOk(find('[data-test-active-deployment]'), 'No active deployment'); - }); -}); - -test('the active deployment section shows up for the currently running deployment', function( - assert -) { - job = server.create('job', { activeDeployment: true, type: 'service' }); - const deployment = server.db.deployments.where({ jobId: job.id })[0]; - const taskGroupSummaries = server.db.deploymentTaskGroupSummaries.where({ - deploymentId: deployment.id, - }); - const version = server.db.jobVersions.findBy({ - jobId: job.id, - version: deployment.versionNumber, - }); - visit(`/jobs/${job.id}`); - - andThen(() => { - assert.ok(find('[data-test-active-deployment]'), 'Active deployment'); - assert.equal( - find('[data-test-active-deployment-stat="id"]').textContent.trim(), - deployment.id.split('-')[0], - 'The active deployment is the most recent running deployment' - ); - - assert.equal( - find('[data-test-active-deployment-stat="submit-time"]').textContent.trim(), - moment(version.submitTime / 1000000).fromNow(), - 'Time since the job was submitted is in the active deployment header' - ); - - assert.equal( - find('[data-test-deployment-metric="canaries"]').textContent.trim(), - `${sum(taskGroupSummaries, 'placedCanaries')} / ${sum( - taskGroupSummaries, - 'desiredCanaries' - )}`, - 'Canaries, both places and desired, are in the metrics' - ); - - assert.equal( - find('[data-test-deployment-metric="placed"]').textContent.trim(), - sum(taskGroupSummaries, 'placedAllocs'), - 'Placed allocs aggregates across task groups' - ); - - assert.equal( - find('[data-test-deployment-metric="desired"]').textContent.trim(), - sum(taskGroupSummaries, 'desiredTotal'), - 'Desired allocs aggregates across task groups' - ); - - assert.equal( - find('[data-test-deployment-metric="healthy"]').textContent.trim(), - sum(taskGroupSummaries, 'healthyAllocs'), - 'Healthy allocs aggregates across task groups' - ); - - assert.equal( - find('[data-test-deployment-metric="unhealthy"]').textContent.trim(), - sum(taskGroupSummaries, 'unhealthyAllocs'), - 'Unhealthy allocs aggregates across task groups' - ); - - assert.equal( - find('[data-test-deployment-notification]').textContent.trim(), - deployment.statusDescription, - 'Status description is in the metrics block' - ); - }); -}); - -test('the active deployment section can be expanded to show task groups and allocations', function( - assert -) { - job = server.create('job', { activeDeployment: true, type: 'service' }); - visit(`/jobs/${job.id}`); - - andThen(() => { - assert.notOk(find('[data-test-deployment-task-groups]'), 'Task groups not found'); - assert.notOk(find('[data-test-deployment-allocations]'), 'Allocations not found'); - }); - - andThen(() => { - click('[data-test-deployment-toggle-details]'); - }); - - andThen(() => { - assert.ok(find('[data-test-deployment-task-groups]'), 'Task groups found'); - assert.ok(find('[data-test-deployment-allocations]'), 'Allocations found'); - }); -}); - -test('the evaluations table lists evaluations sorted by modify index', function(assert) { - job = server.create('job'); - const evaluations = server.db.evaluations - .where({ jobId: job.id }) - .sortBy('modifyIndex') - .reverse(); - - visit(`/jobs/${job.id}`); - - andThen(() => { - assert.equal( - findAll('[data-test-evaluation]').length, - evaluations.length, - 'A row for each evaluation' - ); - - evaluations.forEach((evaluation, index) => { - const row = findAll('[data-test-evaluation]')[index]; - assert.equal( - row.querySelector('[data-test-id]').textContent, - evaluation.id.split('-')[0], - `Short ID, row ${index}` - ); - }); - - const firstEvaluation = evaluations[0]; - const row = find('[data-test-evaluation]'); - assert.equal( - row.querySelector('[data-test-priority]').textContent, - '' + firstEvaluation.priority, - 'Priority' - ); - assert.equal( - row.querySelector('[data-test-triggered-by]').textContent, - firstEvaluation.triggeredBy, - 'Triggered By' - ); - assert.equal( - row.querySelector('[data-test-status]').textContent, - firstEvaluation.status, - 'Status' - ); - }); -}); - -test('when the job has placement failures, they are called out', function(assert) { - job = server.create('job', { failedPlacements: true }); - const failedEvaluation = server.db.evaluations - .where({ jobId: job.id }) - .filter(evaluation => evaluation.failedTGAllocs) - .sortBy('modifyIndex') - .reverse()[0]; - - const failedTaskGroupNames = Object.keys(failedEvaluation.failedTGAllocs); - - visit(`/jobs/${job.id}`); - - andThen(() => { - assert.ok(find('[data-test-placement-failures]'), 'Placement failures section found'); - - const taskGroupLabels = findAll('[data-test-placement-failure-task-group]').map(title => - title.textContent.trim() - ); - failedTaskGroupNames.forEach(name => { - assert.ok( - taskGroupLabels.find(label => label.includes(name)), - `${name} included in placement failures list` - ); - assert.ok( - taskGroupLabels.find(label => - label.includes(failedEvaluation.failedTGAllocs[name].CoalescedFailures + 1) - ), - 'The number of unplaced allocs = CoalescedFailures + 1' - ); - }); - }); -}); - -test('when the job has no placement failures, the placement failures section is gone', function( - assert -) { - job = server.create('job', { noFailedPlacements: true }); - visit(`/jobs/${job.id}`); - - andThen(() => { - assert.notOk(find('[data-test-placement-failures]'), 'Placement failures section not found'); - }); -}); - -test('when the job is not found, an error message is shown, but the URL persists', function( - assert -) { +test('when the job is not found, an error message is shown, but the URL persists', function(assert) { visit('/jobs/not-a-real-job'); andThen(() => { @@ -378,14 +55,12 @@ moduleForAcceptance('Acceptance | job detail (with namespaces)', { beforeEach() { server.createList('namespace', 2); server.create('node'); - job = server.create('job', { namespaceId: server.db.namespaces[1].name }); + job = server.create('job', { type: 'service', namespaceId: server.db.namespaces[1].name }); server.createList('job', 3, { namespaceId: server.db.namespaces[0].name }); }, }); -test('when there are namespaces, the job detail page states the namespace for the job', function( - assert -) { +test('when there are namespaces, the job detail page states the namespace for the job', function(assert) { const namespace = server.db.namespaces.find(job.namespaceId); visit(`/jobs/${job.id}?namespace=${namespace.name}`); @@ -397,9 +72,7 @@ test('when there are namespaces, the job detail page states the namespace for th }); }); -test('when switching namespaces, the app redirects to /jobs with the new namespace', function( - assert -) { +test('when switching namespaces, the app redirects to /jobs with the new namespace', function(assert) { const namespace = server.db.namespaces.find(job.namespaceId); const otherNamespace = server.db.namespaces.toArray().find(ns => ns !== namespace).name; const label = otherNamespace === 'default' ? 'Default Namespace' : otherNamespace; diff --git a/ui/tests/acceptance/jobs-list-test.js b/ui/tests/acceptance/jobs-list-test.js index d7f870214..d7a30a1d1 100644 --- a/ui/tests/acceptance/jobs-list-test.js +++ b/ui/tests/acceptance/jobs-list-test.js @@ -59,7 +59,7 @@ test('each job row should contain information about the job', function(assert) { job.status, 'Status' ); - assert.equal(jobRow.querySelector('[data-test-job-type]').textContent, job.type, 'Type'); + assert.equal(jobRow.querySelector('[data-test-job-type]').textContent, typeForJob(job), 'Type'); assert.equal( jobRow.querySelector('[data-test-job-priority]').textContent, job.priority, @@ -99,9 +99,7 @@ test('when there are no jobs, there is an empty message', function(assert) { }); }); -test('when there are jobs, but no matches for a search result, there is an empty message', function( - assert -) { +test('when there are jobs, but no matches for a search result, there is an empty message', function(assert) { server.create('job', { name: 'cat 1' }); server.create('job', { name: 'cat 2' }); @@ -117,9 +115,7 @@ test('when there are jobs, but no matches for a search result, there is an empty }); }); -test('when the namespace query param is set, only matching jobs are shown and the namespace value is forwarded to app state', function( - assert -) { +test('when the namespace query param is set, only matching jobs are shown and the namespace value is forwarded to app state', function(assert) { server.createList('namespace', 2); const job1 = server.create('job', { namespaceId: server.db.namespaces[0].id }); const job2 = server.create('job', { namespaceId: server.db.namespaces[1].id }); @@ -144,9 +140,7 @@ test('when the namespace query param is set, only matching jobs are shown and th }); }); -test('when accessing jobs is forbidden, show a message with a link to the tokens page', function( - assert -) { +test('when accessing jobs is forbidden, show a message with a link to the tokens page', function(assert) { server.pretender.get('/v1/jobs', () => [403, {}, null]); visit('/jobs'); @@ -163,3 +157,7 @@ test('when accessing jobs is forbidden, show a message with a link to the tokens assert.equal(currentURL(), '/settings/tokens'); }); }); + +function typeForJob(job) { + return job.periodic ? 'periodic' : job.parameterized ? 'parameterized' : job.type; +} diff --git a/ui/tests/acceptance/task-detail-test.js b/ui/tests/acceptance/task-detail-test.js index b0cbc65da..966ff218c 100644 --- a/ui/tests/acceptance/task-detail-test.js +++ b/ui/tests/acceptance/task-detail-test.js @@ -12,18 +12,14 @@ moduleForAcceptance('Acceptance | task detail', { server.create('agent'); server.create('node'); server.create('job', { createAllocations: false }); - allocation = server.create('allocation', 'withTaskWithPorts', { - useMessagePassthru: true, - }); + allocation = server.create('allocation', 'withTaskWithPorts'); task = server.db.taskStates.where({ allocationId: allocation.id })[0]; visit(`/allocations/${allocation.id}/${task.name}`); }, }); -test('/allocation/:id/:task_name should name the task and list high-level task information', function( - assert -) { +test('/allocation/:id/:task_name should name the task and list high-level task information', function(assert) { assert.ok(find('[data-test-title]').textContent.includes(task.name), 'Task name'); assert.ok(find('[data-test-state]').textContent.includes(task.state), 'Task state'); @@ -119,9 +115,7 @@ test('the events table lists all recent events', function(assert) { ); }); -test('each recent event should list the time, type, and description of the event', function( - assert -) { +test('each recent event should list the time, type, and description of the event', function(assert) { const event = server.db.taskEvents.where({ taskStateId: task.id })[0]; const recentEvent = findAll('[data-test-task-event]').get('lastObject'); diff --git a/ui/tests/helpers/module-for-job.js b/ui/tests/helpers/module-for-job.js new file mode 100644 index 000000000..1e1c27fa7 --- /dev/null +++ b/ui/tests/helpers/module-for-job.js @@ -0,0 +1,45 @@ +import { test } from 'qunit'; +import moduleForAcceptance from 'nomad-ui/tests/helpers/module-for-acceptance'; + +export default function moduleForJob(title, jobFactory, additionalTests) { + let job; + + moduleForAcceptance(title, { + beforeEach() { + server.create('node'); + job = jobFactory(); + visit(`/jobs/${job.id}`); + }, + }); + + test('visiting /jobs/:job_id', function(assert) { + assert.equal(currentURL(), `/jobs/${job.id}`); + }); + + test('the subnav links to overview', function(assert) { + click(find('[data-test-tab="overview"] a')); + andThen(() => { + assert.equal(currentURL(), `/jobs/${job.id}`); + }); + }); + + test('the subnav links to definition', function(assert) { + click(find('[data-test-tab="definition"] a')); + andThen(() => { + assert.equal(currentURL(), `/jobs/${job.id}/definition`); + }); + }); + + test('the subnav links to versions', function(assert) { + click(find('[data-test-tab="versions"] a')); + andThen(() => { + assert.equal(currentURL(), `/jobs/${job.id}/versions`); + }); + }); + + for (var testName in additionalTests) { + test(testName, function(assert) { + additionalTests[testName](job, assert); + }); + } +} diff --git a/ui/tests/helpers/start-app.js b/ui/tests/helpers/start-app.js index 2ff98a176..304c6e377 100644 --- a/ui/tests/helpers/start-app.js +++ b/ui/tests/helpers/start-app.js @@ -2,7 +2,7 @@ import { run } from '@ember/runloop'; import { merge } from '@ember/polyfills'; import Application from '../../app'; import config from '../../config/environment'; -import registerPowerSelectHelpers from '../../tests/helpers/ember-power-select'; +import registerPowerSelectHelpers from 'ember-power-select/test-support/helpers'; registerPowerSelectHelpers(); diff --git a/ui/tests/integration/job-page/parts/body-test.js b/ui/tests/integration/job-page/parts/body-test.js new file mode 100644 index 000000000..042d1048b --- /dev/null +++ b/ui/tests/integration/job-page/parts/body-test.js @@ -0,0 +1,137 @@ +import { run } from '@ember/runloop'; +import { getOwner } from '@ember/application'; +import { test, moduleForComponent } from 'ember-qunit'; +import { click, find, findAll } from 'ember-native-dom-helpers'; +import wait from 'ember-test-helpers/wait'; +import hbs from 'htmlbars-inline-precompile'; +import sinon from 'sinon'; +import { clickTrigger } from 'ember-power-select/test-support/helpers'; +import { startMirage } from 'nomad-ui/initializers/ember-cli-mirage'; + +moduleForComponent('job-page/parts/body', 'Integration | Component | job-page/parts/body', { + integration: true, + beforeEach() { + window.localStorage.clear(); + this.server = startMirage(); + this.server.createList('namespace', 3); + }, + afterEach() { + this.server.shutdown(); + window.localStorage.clear(); + }, +}); + +test('includes a subnav for the job', function(assert) { + this.set('job', {}); + this.set('onNamespaceChange', () => {}); + + this.render(hbs` + {{#job-page/parts/body job=job onNamespaceChange=onNamespaceChange}} +
Inner content
+ {{/job-page/parts/body}} + `); + + return wait().then(() => { + assert.ok(find('[data-test-subnav="job"]'), 'Job subnav is rendered'); + }); +}); + +test('the subnav includes the deployments link when the job is a service', function(assert) { + const store = getOwner(this).lookup('service:store'); + let job; + + run(() => { + job = store.createRecord('job', { + id: 'service-job', + type: 'service', + }); + }); + + this.set('job', job); + this.set('onNamespaceChange', () => {}); + + this.render(hbs` + {{#job-page/parts/body job=job onNamespaceChange=onNamespaceChange}} +
Inner content
+ {{/job-page/parts/body}} + `); + + return wait().then(() => { + const subnavLabels = findAll('[data-test-tab]').map(anchor => anchor.textContent); + assert.ok(subnavLabels.some(label => label === 'Definition'), 'Definition link'); + assert.ok(subnavLabels.some(label => label === 'Versions'), 'Versions link'); + assert.ok(subnavLabels.some(label => label === 'Deployments'), 'Deployments link'); + }); +}); + +test('the subnav does not include the deployments link when the job is not a service', function(assert) { + const store = getOwner(this).lookup('service:store'); + let job; + + run(() => { + job = store.createRecord('job', { + id: 'batch-job', + type: 'batch', + }); + }); + + this.set('job', job); + this.set('onNamespaceChange', () => {}); + + this.render(hbs` + {{#job-page/parts/body job=job onNamespaceChange=onNamespaceChange}} +
Inner content
+ {{/job-page/parts/body}} + `); + + return wait().then(() => { + const subnavLabels = findAll('[data-test-tab]').map(anchor => anchor.textContent); + assert.ok(subnavLabels.some(label => label === 'Definition'), 'Definition link'); + assert.ok(subnavLabels.some(label => label === 'Versions'), 'Versions link'); + assert.notOk(subnavLabels.some(label => label === 'Deployments'), 'Deployments link'); + }); +}); + +test('body yields content to a section after the subnav', function(assert) { + this.set('job', {}); + this.set('onNamespaceChange', () => {}); + + this.render(hbs` + {{#job-page/parts/body job=job onNamespaceChange=onNamespaceChange}} +
Inner content
+ {{/job-page/parts/body}} + `); + + return wait().then(() => { + assert.ok( + find('[data-test-page-content] .section > .inner-content'), + 'Content is rendered in a section in a gutter menu' + ); + assert.ok( + find('[data-test-subnav="job"] + .section > .inner-content'), + 'Content is rendered immediately after the subnav' + ); + }); +}); + +test('onNamespaceChange action is called when the namespace changes in the nested gutter menu', function(assert) { + const namespaceSpy = sinon.spy(); + + this.set('job', {}); + this.set('onNamespaceChange', namespaceSpy); + + this.render(hbs` + {{#job-page/parts/body job=job onNamespaceChange=onNamespaceChange}} +
Inner content
+ {{/job-page/parts/body}} + `); + + return wait().then(() => { + clickTrigger('[data-test-namespace-switcher]'); + click(findAll('.ember-power-select-option')[1]); + + return wait().then(() => { + assert.ok(namespaceSpy.calledOnce, 'Switching namespaces calls the onNamespaceChange action'); + }); + }); +}); diff --git a/ui/tests/integration/job-page/parts/children-test.js b/ui/tests/integration/job-page/parts/children-test.js new file mode 100644 index 000000000..9b7149d41 --- /dev/null +++ b/ui/tests/integration/job-page/parts/children-test.js @@ -0,0 +1,206 @@ +import { getOwner } from '@ember/application'; +import { assign } from '@ember/polyfills'; +import { run } from '@ember/runloop'; +import hbs from 'htmlbars-inline-precompile'; +import wait from 'ember-test-helpers/wait'; +import { findAll, find, click } from 'ember-native-dom-helpers'; +import sinon from 'sinon'; +import { test, moduleForComponent } from 'ember-qunit'; +import { startMirage } from 'nomad-ui/initializers/ember-cli-mirage'; + +moduleForComponent('job-page/parts/children', 'Integration | Component | job-page/parts/children', { + integration: true, + beforeEach() { + window.localStorage.clear(); + this.store = getOwner(this).lookup('service:store'); + this.server = startMirage(); + this.server.create('namespace'); + }, + afterEach() { + this.server.shutdown(); + window.localStorage.clear(); + }, +}); + +const props = (job, options = {}) => + assign( + { + job, + sortProperty: 'name', + sortDescending: true, + currentPage: 1, + gotoJob: () => {}, + }, + options + ); + +test('lists each child', function(assert) { + let parent; + + this.server.create('job', 'periodic', { + id: 'parent', + childrenCount: 3, + createAllocations: false, + }); + + this.store.findAll('job'); + + return wait().then(() => { + run(() => { + parent = this.store.peekAll('job').findBy('plainId', 'parent'); + }); + + this.setProperties(props(parent)); + + this.render(hbs` + {{job-page/parts/children + job=job + sortProperty=sortProperty + sortDescending=sortDescending + currentPage=currentPage + gotoJob=gotoJob}} + `); + + return wait().then(() => { + assert.equal( + findAll('[data-test-job-name]').length, + parent.get('children.length'), + 'A row for each child' + ); + }); + }); +}); + +test('eventually paginates', function(assert) { + let parent; + + this.server.create('job', 'periodic', { + id: 'parent', + childrenCount: 11, + createAllocations: false, + }); + + this.store.findAll('job'); + + return wait().then(() => { + run(() => { + parent = this.store.peekAll('job').findBy('plainId', 'parent'); + }); + + this.setProperties(props(parent)); + + this.render(hbs` + {{job-page/parts/children + job=job + sortProperty=sortProperty + sortDescending=sortDescending + currentPage=currentPage + gotoJob=gotoJob}} + `); + + return wait().then(() => { + const childrenCount = parent.get('children.length'); + assert.ok(childrenCount > 10, 'Parent has more children than one page size'); + assert.equal(findAll('[data-test-job-name]').length, 10, 'Table length maxes out at 10'); + assert.ok(find('.pagination-next'), 'Next button is rendered'); + + assert.ok( + new RegExp(`1.10.+?${childrenCount}`).test(find('.pagination-numbers').textContent.trim()) + ); + }); + }); +}); + +test('is sorted based on the sortProperty and sortDescending properties', function(assert) { + let parent; + + this.server.create('job', 'periodic', { + id: 'parent', + childrenCount: 3, + createAllocations: false, + }); + + this.store.findAll('job'); + + return wait().then(() => { + run(() => { + parent = this.store.peekAll('job').findBy('plainId', 'parent'); + }); + + this.setProperties(props(parent)); + + this.render(hbs` + {{job-page/parts/children + job=job + sortProperty=sortProperty + sortDescending=sortDescending + currentPage=currentPage + gotoJob=gotoJob}} + `); + + return wait().then(() => { + const sortedChildren = parent.get('children').sortBy('name'); + const childRows = findAll('[data-test-job-name]'); + + sortedChildren.reverse().forEach((child, index) => { + assert.equal( + childRows[index].textContent.trim(), + child.get('name'), + `Child ${index} is ${child.get('name')}` + ); + }); + + this.set('sortDescending', false); + + sortedChildren.forEach((child, index) => { + assert.equal( + childRows[index].textContent.trim(), + child.get('name'), + `Child ${index} is ${child.get('name')}` + ); + }); + }); + }); +}); + +test('gotoJob is called when a job row is clicked', function(assert) { + let parent; + const gotoJobSpy = sinon.spy(); + + this.server.create('job', 'periodic', { + id: 'parent', + childrenCount: 1, + createAllocations: false, + }); + + this.store.findAll('job'); + + return wait().then(() => { + run(() => { + parent = this.store.peekAll('job').findBy('plainId', 'parent'); + }); + + this.setProperties( + props(parent, { + gotoJob: gotoJobSpy, + }) + ); + + this.render(hbs` + {{job-page/parts/children + job=job + sortProperty=sortProperty + sortDescending=sortDescending + currentPage=currentPage + gotoJob=gotoJob}} + `); + + return wait().then(() => { + click('tr.job-row'); + assert.ok( + gotoJobSpy.withArgs(parent.get('children.firstObject')).calledOnce, + 'Clicking the job row calls the gotoJob action' + ); + }); + }); +}); diff --git a/ui/tests/integration/job-page/parts/evaluations-test.js b/ui/tests/integration/job-page/parts/evaluations-test.js new file mode 100644 index 000000000..982c1d294 --- /dev/null +++ b/ui/tests/integration/job-page/parts/evaluations-test.js @@ -0,0 +1,65 @@ +import { run } from '@ember/runloop'; +import { getOwner } from '@ember/application'; +import { test, moduleForComponent } from 'ember-qunit'; +import { findAll } from 'ember-native-dom-helpers'; +import wait from 'ember-test-helpers/wait'; +import hbs from 'htmlbars-inline-precompile'; +import { startMirage } from 'nomad-ui/initializers/ember-cli-mirage'; + +moduleForComponent( + 'job-page/parts/evaluations', + 'Integration | Component | job-page/parts/evaluations', + { + integration: true, + beforeEach() { + window.localStorage.clear(); + this.store = getOwner(this).lookup('service:store'); + this.server = startMirage(); + this.server.create('namespace'); + }, + afterEach() { + this.server.shutdown(); + window.localStorage.clear(); + }, + } +); + +test('lists all evaluations for the job', function(assert) { + let job; + + this.server.create('job', { noFailedPlacements: true, createAllocations: false }); + this.store.findAll('job'); + + return wait().then(() => { + run(() => { + job = this.store.peekAll('job').get('firstObject'); + }); + + this.setProperties({ job }); + + this.render(hbs` + {{job-page/parts/evaluations job=job}} + `); + + return wait().then(() => { + const evaluationRows = findAll('[data-test-evaluation]'); + assert.equal( + evaluationRows.length, + job.get('evaluations.length'), + 'All evaluations are listed' + ); + + job + .get('evaluations') + .sortBy('modifyIndex') + .reverse() + .forEach((evaluation, index) => { + assert.equal( + evaluationRows[index].querySelector('[data-test-id]').textContent.trim(), + evaluation.get('shortId'), + `Evaluation ${index} is ${evaluation.get('shortId')}` + ); + }); + }); + }); +}); diff --git a/ui/tests/integration/job-page/parts/placement-failures-test.js b/ui/tests/integration/job-page/parts/placement-failures-test.js new file mode 100644 index 000000000..844e51c5b --- /dev/null +++ b/ui/tests/integration/job-page/parts/placement-failures-test.js @@ -0,0 +1,90 @@ +import { getOwner } from '@ember/application'; +import { run } from '@ember/runloop'; +import hbs from 'htmlbars-inline-precompile'; +import wait from 'ember-test-helpers/wait'; +import { findAll, find } from 'ember-native-dom-helpers'; +import { test, moduleForComponent } from 'ember-qunit'; +import { startMirage } from 'nomad-ui/initializers/ember-cli-mirage'; +import { initialize as fragmentSerializerInitializer } from 'nomad-ui/initializers/fragment-serializer'; + +moduleForComponent( + 'job-page/parts/placement-failures', + 'Integration | Component | job-page/parts/placement-failures', + { + integration: true, + beforeEach() { + fragmentSerializerInitializer(getOwner(this)); + window.localStorage.clear(); + this.store = getOwner(this).lookup('service:store'); + this.server = startMirage(); + this.server.create('namespace'); + }, + afterEach() { + this.server.shutdown(); + window.localStorage.clear(); + }, + } +); + +test('when the job has placement failures, they are called out', function(assert) { + this.server.create('job', { failedPlacements: true, createAllocations: false }); + this.store.findAll('job').then(jobs => { + jobs.forEach(job => job.reload()); + }); + + return wait().then(() => { + run(() => { + this.set('job', this.store.peekAll('job').get('firstObject')); + }); + + this.render(hbs` + {{job-page/parts/placement-failures job=job}}) + `); + + return wait().then(() => { + const failedEvaluation = this.get('job.evaluations') + .filterBy('hasPlacementFailures') + .sortBy('modifyIndex') + .reverse() + .get('firstObject'); + const failedTGAllocs = failedEvaluation.get('failedTGAllocs'); + + assert.ok(find('[data-test-placement-failures]'), 'Placement failures section found'); + + const taskGroupLabels = findAll('[data-test-placement-failure-task-group]').map(title => + title.textContent.trim() + ); + + failedTGAllocs.forEach(alloc => { + const name = alloc.get('name'); + assert.ok( + taskGroupLabels.find(label => label.includes(name)), + `${name} included in placement failures list` + ); + assert.ok( + taskGroupLabels.find(label => label.includes(alloc.get('coalescedFailures') + 1)), + 'The number of unplaced allocs = CoalescedFailures + 1' + ); + }); + }); + }); +}); + +test('when the job has no placement failures, the placement failures section is gone', function(assert) { + this.server.create('job', { noFailedPlacements: true, createAllocations: false }); + this.store.findAll('job'); + + return wait().then(() => { + run(() => { + this.set('job', this.store.peekAll('job').get('firstObject')); + }); + + this.render(hbs` + {{job-page/parts/placement-failures job=job}}) + `); + + return wait().then(() => { + assert.notOk(find('[data-test-placement-failures]'), 'Placement failures section not found'); + }); + }); +}); diff --git a/ui/tests/integration/job-page/parts/running-deployment-test.js b/ui/tests/integration/job-page/parts/running-deployment-test.js new file mode 100644 index 000000000..e6cb09bbd --- /dev/null +++ b/ui/tests/integration/job-page/parts/running-deployment-test.js @@ -0,0 +1,141 @@ +import { getOwner } from '@ember/application'; +import { test, moduleForComponent } from 'ember-qunit'; +import { click, find } from 'ember-native-dom-helpers'; +import wait from 'ember-test-helpers/wait'; +import hbs from 'htmlbars-inline-precompile'; +import moment from 'moment'; +import { startMirage } from 'nomad-ui/initializers/ember-cli-mirage'; +import { initialize as fragmentSerializerInitializer } from 'nomad-ui/initializers/fragment-serializer'; + +moduleForComponent( + 'job-page/parts/running-deployment', + 'Integration | Component | job-page/parts/running-deployment', + { + integration: true, + beforeEach() { + fragmentSerializerInitializer(getOwner(this)); + window.localStorage.clear(); + this.store = getOwner(this).lookup('service:store'); + this.server = startMirage(); + this.server.create('namespace'); + }, + afterEach() { + this.server.shutdown(); + window.localStorage.clear(); + }, + } +); + +test('there is no active deployment section when the job has no active deployment', function(assert) { + this.server.create('job', { + type: 'service', + noActiveDeployment: true, + createAllocations: false, + }); + + this.store.findAll('job'); + + return wait().then(() => { + this.set('job', this.store.peekAll('job').get('firstObject')); + this.render(hbs` + {{job-page/parts/running-deployment job=job}}) + `); + + return wait().then(() => { + assert.notOk(find('[data-test-active-deployment]'), 'No active deployment'); + }); + }); +}); + +test('the active deployment section shows up for the currently running deployment', function(assert) { + this.server.create('job', { type: 'service', createAllocations: false, activeDeployment: true }); + + this.store.findAll('job'); + + return wait().then(() => { + this.set('job', this.store.peekAll('job').get('firstObject')); + this.render(hbs` + {{job-page/parts/running-deployment job=job}} + `); + + return wait().then(() => { + const deployment = this.get('job.runningDeployment'); + const version = deployment.get('version'); + + assert.ok(find('[data-test-active-deployment]'), 'Active deployment'); + assert.equal( + find('[data-test-active-deployment-stat="id"]').textContent.trim(), + deployment.get('shortId'), + 'The active deployment is the most recent running deployment' + ); + + assert.equal( + find('[data-test-active-deployment-stat="submit-time"]').textContent.trim(), + moment(version.get('submitTime')).fromNow(), + 'Time since the job was submitted is in the active deployment header' + ); + + assert.equal( + find('[data-test-deployment-metric="canaries"]').textContent.trim(), + `${deployment.get('placedCanaries')} / ${deployment.get('desiredCanaries')}`, + 'Canaries, both places and desired, are in the metrics' + ); + + assert.equal( + find('[data-test-deployment-metric="placed"]').textContent.trim(), + deployment.get('placedAllocs'), + 'Placed allocs aggregates across task groups' + ); + + assert.equal( + find('[data-test-deployment-metric="desired"]').textContent.trim(), + deployment.get('desiredTotal'), + 'Desired allocs aggregates across task groups' + ); + + assert.equal( + find('[data-test-deployment-metric="healthy"]').textContent.trim(), + deployment.get('healthyAllocs'), + 'Healthy allocs aggregates across task groups' + ); + + assert.equal( + find('[data-test-deployment-metric="unhealthy"]').textContent.trim(), + deployment.get('unhealthyAllocs'), + 'Unhealthy allocs aggregates across task groups' + ); + + assert.equal( + find('[data-test-deployment-notification]').textContent.trim(), + deployment.get('statusDescription'), + 'Status description is in the metrics block' + ); + }); + }); +}); + +test('the active deployment section can be expanded to show task groups and allocations', function(assert) { + this.server.create('node'); + this.server.create('job', { type: 'service', activeDeployment: true }); + + this.store.findAll('job'); + + return wait().then(() => { + this.set('job', this.store.peekAll('job').get('firstObject')); + this.render(hbs` + {{job-page/parts/running-deployment job=job}} + `); + + return wait().then(() => { + assert.notOk(find('[data-test-deployment-task-groups]'), 'Task groups not found'); + assert.notOk(find('[data-test-deployment-allocations]'), 'Allocations not found'); + + click('[data-test-deployment-toggle-details]'); + + return wait().then(() => { + assert.ok(find('[data-test-deployment-task-groups]'), 'Task groups found'); + assert.ok(find('[data-test-deployment-allocations]'), 'Allocations found'); + }); + }); + }); +}); diff --git a/ui/tests/integration/job-page/parts/summary-test.js b/ui/tests/integration/job-page/parts/summary-test.js new file mode 100644 index 000000000..186c4ffcf --- /dev/null +++ b/ui/tests/integration/job-page/parts/summary-test.js @@ -0,0 +1,154 @@ +import { getOwner } from '@ember/application'; +import hbs from 'htmlbars-inline-precompile'; +import wait from 'ember-test-helpers/wait'; +import { find } from 'ember-native-dom-helpers'; +import { test, moduleForComponent } from 'ember-qunit'; +import { startMirage } from 'nomad-ui/initializers/ember-cli-mirage'; +import { initialize as fragmentSerializerInitializer } from 'nomad-ui/initializers/fragment-serializer'; + +moduleForComponent('job-page/parts/summary', 'Integration | Component | job-page/parts/summary', { + integration: true, + beforeEach() { + fragmentSerializerInitializer(getOwner(this)); + window.localStorage.clear(); + this.store = getOwner(this).lookup('service:store'); + this.server = startMirage(); + this.server.create('namespace'); + }, + afterEach() { + this.server.shutdown(); + window.localStorage.clear(); + }, +}); + +test('jobs with children use the children diagram', function(assert) { + this.server.create('job', 'periodic', { + createAllocations: false, + }); + + this.store.findAll('job'); + + return wait().then(() => { + this.set('job', this.store.peekAll('job').get('firstObject')); + + this.render(hbs` + {{job-page/parts/summary job=job}} + `); + + return wait().then(() => { + assert.ok(find('[data-test-children-status-bar]'), 'Children status bar found'); + assert.notOk(find('[data-test-allocation-status-bar]'), 'Allocation status bar not found'); + }); + }); +}); + +test('jobs without children use the allocations diagram', function(assert) { + this.server.create('job', { + createAllocations: false, + }); + + this.store.findAll('job'); + + return wait().then(() => { + this.set('job', this.store.peekAll('job').get('firstObject')); + + this.render(hbs` + {{job-page/parts/summary job=job}} + `); + + return wait().then(() => { + assert.ok(find('[data-test-allocation-status-bar]'), 'Allocation status bar found'); + assert.notOk(find('[data-test-children-status-bar]'), 'Children status bar not found'); + }); + }); +}); + +test('the allocations diagram lists all allocation status figures', function(assert) { + this.server.create('job', { + createAllocations: false, + }); + + this.store.findAll('job'); + + return wait().then(() => { + this.set('job', this.store.peekAll('job').get('firstObject')); + + this.render(hbs` + {{job-page/parts/summary job=job}} + `); + + return wait().then(() => { + assert.equal( + find('[data-test-legend-value="queued"]').textContent, + this.get('job.queuedAllocs'), + `${this.get('job.queuedAllocs')} are queued` + ); + + assert.equal( + find('[data-test-legend-value="starting"]').textContent, + this.get('job.startingAllocs'), + `${this.get('job.startingAllocs')} are starting` + ); + + assert.equal( + find('[data-test-legend-value="running"]').textContent, + this.get('job.runningAllocs'), + `${this.get('job.runningAllocs')} are running` + ); + + assert.equal( + find('[data-test-legend-value="complete"]').textContent, + this.get('job.completeAllocs'), + `${this.get('job.completeAllocs')} are complete` + ); + + assert.equal( + find('[data-test-legend-value="failed"]').textContent, + this.get('job.failedAllocs'), + `${this.get('job.failedAllocs')} are failed` + ); + + assert.equal( + find('[data-test-legend-value="lost"]').textContent, + this.get('job.lostAllocs'), + `${this.get('job.lostAllocs')} are lost` + ); + }); + }); +}); + +test('the children diagram lists all children status figures', function(assert) { + this.server.create('job', 'periodic', { + createAllocations: false, + }); + + this.store.findAll('job'); + + return wait().then(() => { + this.set('job', this.store.peekAll('job').get('firstObject')); + + this.render(hbs` + {{job-page/parts/summary job=job}} + `); + + return wait().then(() => { + assert.equal( + find('[data-test-legend-value="queued"]').textContent, + this.get('job.pendingChildren'), + `${this.get('job.pendingChildren')} are pending` + ); + + assert.equal( + find('[data-test-legend-value="running"]').textContent, + this.get('job.runningChildren'), + `${this.get('job.runningChildren')} are running` + ); + + assert.equal( + find('[data-test-legend-value="complete"]').textContent, + this.get('job.deadChildren'), + `${this.get('job.deadChildren')} are dead` + ); + }); + }); +}); diff --git a/ui/tests/integration/job-page/parts/task-groups-test.js b/ui/tests/integration/job-page/parts/task-groups-test.js new file mode 100644 index 000000000..f9ac811f8 --- /dev/null +++ b/ui/tests/integration/job-page/parts/task-groups-test.js @@ -0,0 +1,170 @@ +import { getOwner } from '@ember/application'; +import { assign } from '@ember/polyfills'; +import hbs from 'htmlbars-inline-precompile'; +import wait from 'ember-test-helpers/wait'; +import { click, findAll, find } from 'ember-native-dom-helpers'; +import { test, moduleForComponent } from 'ember-qunit'; +import sinon from 'sinon'; +import { startMirage } from 'nomad-ui/initializers/ember-cli-mirage'; +import { initialize as fragmentSerializerInitializer } from 'nomad-ui/initializers/fragment-serializer'; + +moduleForComponent( + 'job-page/parts/task-groups', + 'Integration | Component | job-page/parts/task-groups', + { + integration: true, + beforeEach() { + fragmentSerializerInitializer(getOwner(this)); + window.localStorage.clear(); + this.store = getOwner(this).lookup('service:store'); + this.server = startMirage(); + this.server.create('namespace'); + }, + afterEach() { + this.server.shutdown(); + }, + } +); + +const props = (job, options = {}) => + assign( + { + job, + sortProperty: 'name', + sortDescending: true, + gotoTaskGroup: () => {}, + }, + options + ); + +test('the job detail page should list all task groups', function(assert) { + this.server.create('job', { + createAllocations: false, + }); + + this.store.findAll('job').then(jobs => { + jobs.forEach(job => job.reload()); + }); + + return wait().then(() => { + const job = this.store.peekAll('job').get('firstObject'); + this.setProperties(props(job)); + + this.render(hbs` + {{job-page/parts/task-groups + job=job + sortProperty=sortProperty + sortDescending=sortDescending + gotoTaskGroup=gotoTaskGroup}} + `); + + return wait().then(() => { + assert.equal( + findAll('[data-test-task-group]').length, + job.get('taskGroups.length'), + 'One row per task group' + ); + }); + }); +}); + +test('each row in the task group table should show basic information about the task group', function(assert) { + this.server.create('job', { + createAllocations: false, + }); + + this.store.findAll('job').then(jobs => { + jobs.forEach(job => job.reload()); + }); + + return wait().then(() => { + const job = this.store.peekAll('job').get('firstObject'); + const taskGroup = job + .get('taskGroups') + .sortBy('name') + .reverse() + .get('firstObject'); + + this.setProperties(props(job)); + + this.render(hbs` + {{job-page/parts/task-groups + job=job + sortProperty=sortProperty + sortDescending=sortDescending + gotoTaskGroup=gotoTaskGroup}} + `); + + return wait().then(() => { + const taskGroupRow = find('[data-test-task-group]'); + + assert.equal( + taskGroupRow.querySelector('[data-test-task-group-name]').textContent.trim(), + taskGroup.get('name'), + 'Name' + ); + assert.equal( + taskGroupRow.querySelector('[data-test-task-group-count]').textContent.trim(), + taskGroup.get('count'), + 'Count' + ); + assert.equal( + taskGroupRow.querySelector('[data-test-task-group-cpu]').textContent.trim(), + `${taskGroup.get('reservedCPU')} MHz`, + 'Reserved CPU' + ); + assert.equal( + taskGroupRow.querySelector('[data-test-task-group-mem]').textContent.trim(), + `${taskGroup.get('reservedMemory')} MiB`, + 'Reserved Memory' + ); + assert.equal( + taskGroupRow.querySelector('[data-test-task-group-disk]').textContent.trim(), + `${taskGroup.get('reservedEphemeralDisk')} MiB`, + 'Reserved Disk' + ); + }); + }); +}); + +test('gotoTaskGroup is called when task group rows are clicked', function(assert) { + this.server.create('job', { + createAllocations: false, + }); + + this.store.findAll('job').then(jobs => { + jobs.forEach(job => job.reload()); + }); + + return wait().then(() => { + const taskGroupSpy = sinon.spy(); + const job = this.store.peekAll('job').get('firstObject'); + const taskGroup = job + .get('taskGroups') + .sortBy('name') + .reverse() + .get('firstObject'); + + this.setProperties( + props(job, { + gotoTaskGroup: taskGroupSpy, + }) + ); + + this.render(hbs` + {{job-page/parts/task-groups + job=job + sortProperty=sortProperty + sortDescending=sortDescending + gotoTaskGroup=gotoTaskGroup}} + `); + + return wait().then(() => { + click('[data-test-task-group]'); + assert.ok( + taskGroupSpy.withArgs(taskGroup).calledOnce, + 'Clicking the task group row calls the gotoTaskGroup action' + ); + }); + }); +}); diff --git a/ui/tests/integration/job-page/periodic-test.js b/ui/tests/integration/job-page/periodic-test.js new file mode 100644 index 000000000..c259f0167 --- /dev/null +++ b/ui/tests/integration/job-page/periodic-test.js @@ -0,0 +1,86 @@ +import { getOwner } from '@ember/application'; +import { test, moduleForComponent } from 'ember-qunit'; +import { click, findAll } from 'ember-native-dom-helpers'; +import wait from 'ember-test-helpers/wait'; +import hbs from 'htmlbars-inline-precompile'; +import { startMirage } from 'nomad-ui/initializers/ember-cli-mirage'; + +moduleForComponent('job-page/periodic', 'Integration | Component | job-page/periodic', { + integration: true, + beforeEach() { + window.localStorage.clear(); + this.store = getOwner(this).lookup('service:store'); + this.server = startMirage(); + this.server.create('namespace'); + }, + afterEach() { + this.server.shutdown(); + window.localStorage.clear(); + }, +}); + +test('Clicking Force Launch launches a new periodic child job', function(assert) { + const childrenCount = 3; + + this.server.create('job', 'periodic', { + id: 'parent', + childrenCount, + createAllocations: false, + }); + + this.store.findAll('job'); + + return wait().then(() => { + const job = this.store.peekAll('job').findBy('plainId', 'parent'); + this.setProperties({ + job, + sortProperty: 'name', + sortDescending: true, + currentPage: 1, + gotoJob: () => {}, + }); + + this.render(hbs` + {{job-page/periodic + job=job + sortProperty=sortProperty + sortDescending=sortDescending + currentPage=currentPage + gotoJob=gotoJob}} + `); + + return wait().then(() => { + const currentJobCount = server.db.jobs.length; + + assert.equal( + findAll('[data-test-job-name]').length, + childrenCount, + 'The new periodic job launch is in the children list' + ); + + click('[data-test-force-launch]'); + + return wait().then(() => { + const id = job.get('plainId'); + const namespace = job.get('namespace.name') || 'default'; + + assert.ok( + server.pretender.handledRequests + .filterBy('method', 'POST') + .find(req => req.url === `/v1/job/${id}/periodic/force?namespace=${namespace}`), + 'POST URL was correct' + ); + + assert.ok(server.db.jobs.length, currentJobCount + 1, 'POST request was made'); + + return wait().then(() => { + assert.equal( + findAll('[data-test-job-name]').length, + childrenCount + 1, + 'The new periodic job launch is in the children list' + ); + }); + }); + }); + }); +}); diff --git a/website/source/api/agent.html.md b/website/source/api/agent.html.md index 6172d2e02..b8b390fa6 100644 --- a/website/source/api/agent.html.md +++ b/website/source/api/agent.html.md @@ -279,6 +279,7 @@ $ curl \ "CirconusSubmissionInterval": "", "CollectionInterval": "1s", "DataDogAddr": "", + "DataDogTags": [], "DisableHostname": false, "PublishAllocationMetrics": false, "PublishNodeMetrics": false, diff --git a/website/source/api/operator.html.md b/website/source/api/operator.html.md index 98226bfe9..edc6115de 100644 --- a/website/source/api/operator.html.md +++ b/website/source/api/operator.html.md @@ -168,9 +168,9 @@ $ curl \ "LastContactThreshold": "200ms", "MaxTrailingLogs": 250, "ServerStabilizationTime": "10s", - "RedundancyZoneTag": "", + "EnableRedundancyZones": false, "DisableUpgradeMigration": false, - "UpgradeVersionTag": "", + "EnableCustomUpgrades": false, "CreateIndex": 4, "ModifyIndex": 4 } @@ -221,19 +221,16 @@ The table below shows this endpoint's support for cluster. Only takes effect if all servers are running Raft protocol version 3 or higher. Must be a duration value such as `30s`. -- `RedundancyZoneTag` `(string: "")` - Controls the node-meta key to use when - Autopilot is separating servers into zones for redundancy. Only one server in - each zone can be a voting member at one time. If left blank, this feature will - be disabled. +- `EnableRedundancyZones` `(bool: false)` - (Enterprise-only) Specifies whether + to enable redundancy zones. -- `DisableUpgradeMigration` `(bool: false)` - Disables Autopilot's upgrade - migration strategy in Nomad Enterprise of waiting until enough +- `DisableUpgradeMigration` `(bool: false)` - (Enterprise-only) Disables Autopilot's + upgrade migration strategy in Nomad Enterprise of waiting until enough newer-versioned servers have been added to the cluster before promoting any of them to voters. -- `UpgradeVersionTag` `(string: "")` - Controls the node-meta key to use for - version info when performing upgrade migrations. If left blank, the Nomad - version will be used. +- `EnableCustomUpgrades` `(bool: false)` - (Enterprise-only) Specifies whether to + enable using custom upgrade versions when performing migrations. ### Sample Payload @@ -243,9 +240,9 @@ The table below shows this endpoint's support for "LastContactThreshold": "200ms", "MaxTrailingLogs": 250, "ServerStabilizationTime": "10s", - "RedundancyZoneTag": "", + "EnableRedundancyZones": false, "DisableUpgradeMigration": false, - "UpgradeVersionTag": "", + "EnableCustomUpgrades": false, "CreateIndex": 4, "ModifyIndex": 4 } diff --git a/website/source/docs/agent/configuration/autopilot.html.md b/website/source/docs/agent/configuration/autopilot.html.md index ed13f37aa..5328c0174 100644 --- a/website/source/docs/agent/configuration/autopilot.html.md +++ b/website/source/docs/agent/configuration/autopilot.html.md @@ -18,6 +18,7 @@ description: |- The `autopilot` stanza configures the Nomad agent to configure Autopilot behavior. +For more information about Autopilot, see the [Autopilot Guide](/guides/cluster/autopilot.html). ```hcl autopilot { @@ -25,9 +26,9 @@ autopilot { last_contact_threshold = "200ms" max_trailing_logs = 250 server_stabilization_time = "10s" - redundancy_zone_tag = "" - disable_upgrade_migration = true - upgrade_version_tag = "" + enable_redundancy_zones = false + disable_upgrade_migration = false + enable_custom_upgrades = false } ``` @@ -48,17 +49,17 @@ autopilot { cluster. Only takes effect if all servers are running Raft protocol version 3 or higher. Must be a duration value such as `30s`. -- `redundancy_zone_tag` `(string: "")` - Controls the node-meta key to use when - Autopilot is separating servers into zones for redundancy. Only one server in - each zone can be a voting member at one time. If left blank, this feature will - be disabled. +- `enable_redundancy_zones` `(bool: false)` - (Enterprise-only) Controls whether + Autopilot separates servers into zones for redundancy, in conjunction with the + [redundancy_zone](/docs/agent/configuration/server.html#redundancy_zone) parameter. + Only one server in each zone can be a voting member at one time. -- `disable_upgrade_migration` `(bool: false)` - Disables Autopilot's upgrade - migration strategy in Nomad Enterprise of waiting until enough +- `disable_upgrade_migration` `(bool: false)` - (Enterprise-only) Disables Autopilot's + upgrade migration strategy in Nomad Enterprise of waiting until enough newer-versioned servers have been added to the cluster before promoting any of them to voters. -- `upgrade_version_tag` `(string: "")` - Controls the node-meta key to use for - version info when performing upgrade migrations. If left blank, the Nomad - version will be used. +- `enable_custom_upgrades` `(bool: false)` - (Enterprise-only) Specifies whether to + enable using custom upgrade versions when performing migrations, in conjunction with + the [upgrade_version](/docs/agent/configuration/server.html#upgrade_version) parameter. diff --git a/website/source/docs/agent/configuration/server.html.md b/website/source/docs/agent/configuration/server.html.md index 92854ae18..56b5e4abc 100644 --- a/website/source/docs/agent/configuration/server.html.md +++ b/website/source/docs/agent/configuration/server.html.md @@ -102,8 +102,9 @@ server { second is a tradeoff as it lowers failure detection time of nodes at the tradeoff of false positives and increased load on the leader. -- `non_voting_server` `(bool: false)` - is whether this server will act as - a non-voting member of the cluster to help provide read scalability. (Enterprise-only) +- `non_voting_server` `(bool: false)` - (Enterprise-only) Specifies whether + this server will act as a non-voting member of the cluster to help provide + read scalability. - `num_schedulers` `(int: [num-cores])` - Specifies the number of parallel scheduler threads to run. This can be as many as one per core, or `0` to @@ -120,6 +121,10 @@ server { features and is typically not required as the agent internally knows the latest version, but may be useful in some upgrade scenarios. +- `redundancy_zone` `(string: "")` - (Enterprise-only) Specifies the redundancy + zone that this server will be a part of for Autopilot management. For more + information, see the [Autopilot Guide](/guides/cluster/autopilot.html). + - `rejoin_after_leave` `(bool: false)` - Specifies if Nomad will ignore a previous leave and attempt to rejoin the cluster when starting. By default, Nomad treats leave as a permanent intent and does not attempt to join the @@ -149,6 +154,10 @@ server { [server address format](#server-address-format) section for more information on the format of the string. +- `upgrade_version` `(string: "")` - A custom version of the format X.Y.Z to use + in place of the Nomad version when custom upgrades are enabled in Autopilot. + For more information, see the [Autopilot Guide](/guides/cluster/autopilot.html). + ### Server Address Format This section describes the acceptable syntax and format for describing the diff --git a/website/source/docs/agent/configuration/telemetry.html.md b/website/source/docs/agent/configuration/telemetry.html.md index b32af1e81..bf5b486e3 100644 --- a/website/source/docs/agent/configuration/telemetry.html.md +++ b/website/source/docs/agent/configuration/telemetry.html.md @@ -108,9 +108,15 @@ These `telemetry` parameters apply to - `datadog_address` `(string: "")` - Specifies the address of a DataDog statsd server to forward metrics to. +- `datadog_tags` `(list: [])` - Specifies a list of global tags that will be + added to all telemetry packets sent to DogStatsD. It is a list of strings, + where each string looks like "my_tag_name:my_tag_value". + + ```hcl telemetry { datadog_address = "dogstatsd.company.local:8125" + datadog_tags = ["my_tag_name:my_tag_value"] } ``` diff --git a/website/source/docs/drivers/docker.html.md b/website/source/docs/drivers/docker.html.md index c75238dfd..99ef6de58 100644 --- a/website/source/docs/drivers/docker.html.md +++ b/website/source/docs/drivers/docker.html.md @@ -355,6 +355,9 @@ The `docker` driver supports the following configuration in the job spec. Only ] } ``` +* `advertise_ipv6_address` - (Optional) `true` or `false` (default). Use the container's + IPv6 address (GlobalIPv6Address in Docker) when registering services and checks. + See [IPv6 Docker containers](/docs/job-specification/service.html#IPv6 Docker containers) for details. * `readonly_rootfs` - (Optional) `true` or `false` (default). Mount the container's filesystem as read only. diff --git a/website/source/docs/job-specification/service.html.md b/website/source/docs/job-specification/service.html.md index 54f429075..6d04e8132 100644 --- a/website/source/docs/job-specification/service.html.md +++ b/website/source/docs/job-specification/service.html.md @@ -463,6 +463,109 @@ In this case Nomad doesn't need to assign Redis any host ports. The `service` and `check` stanzas can both specify the port number to advertise and check directly since Nomad isn't managing any port assignments. +### IPv6 Docker containers + +The [Docker](/docs/drivers/docker.html#advertise_ipv6_address) driver supports the +`advertise_ipv6_address` parameter in it's configuration. + +Services will automatically advertise the IPv6 address when `advertise_ipv6_address` +is used. + +Unlike services, checks do not have an `auto` address mode as there's no way +for Nomad to know which is the best address to use for checks. Consul needs +access to the address for any HTTP or TCP checks. + +So you have to set `address_mode` parameter in the `check` stanza to `driver`. + +For example using `auto` address mode: + +```hcl +job "example" { + datacenters = ["dc1"] + group "cache" { + + task "redis" { + driver = "docker" + + config { + image = "redis:3.2" + advertise_ipv6_address = true + port_map { + db = 6379 + } + } + + resources { + cpu = 500 # 500 MHz + memory = 256 # 256MB + network { + mbits = 10 + port "db" {} + } + } + + service { + name = "ipv6-redis" + port = db + check { + name = "ipv6-redis-check" + type = "tcp" + interval = "10s" + timeout = "2s" + port = db + address_mode = "driver" + } + } + } + } +} +``` + +Or using `address_mode=driver` for `service` and `check` with numeric ports: + +```hcl +job "example" { + datacenters = ["dc1"] + group "cache" { + + task "redis" { + driver = "docker" + + config { + image = "redis:3.2" + advertise_ipv6_address = true + # No port map required! + } + + resources { + cpu = 500 # 500 MHz + memory = 256 # 256MB + network { + mbits = 10 + } + } + + service { + name = "ipv6-redis" + port = 6379 + address_mode = "driver" + check { + name = "ipv6-redis-check" + type = "tcp" + interval = "10s" + timeout = "2s" + port = 6379 + address_mode = "driver" + } + } + } + } +} +``` + +The `service` and `check` stanzas can both specify the port number to +advertise and check directly since Nomad isn't managing any port assignments. + - - - diff --git a/website/source/guides/acl.html.markdown b/website/source/guides/acl.html.markdown index cf6788f38..b58b9eab1 100644 --- a/website/source/guides/acl.html.markdown +++ b/website/source/guides/acl.html.markdown @@ -370,3 +370,150 @@ Error bootstrapping: Unexpected response code: 500 (Invalid bootstrap reset inde This is because the reset file is in place, but with the incorrect index. The reset file can be deleted, but Nomad will not reset the bootstrap until the index is corrected. +## Vault Integration +Hashicorp Vault has a secret backend for generating short-lived Nomad tokens. As Vault has a number of +authentication backends, it could provide a workflow where a user or orchestration system authenticates +using an pre-existing identity service (LDAP, Okta, Amazon IAM, etc.) in order to obtain a short-lived +Nomad token. + +~> Hashicorp Vault is a standalone product with it's own set of deployment and + configuration best practices. Please review [Vault's + documentation](https://www.vaultproject.io/docs/index.html) before deploying it + in production. + +For evaluation purposes, a Vault server in "dev" mode can be used. + +``` +$ vault server -dev +==> Vault server configuration: + + Cgo: disabled + Cluster Address: https://127.0.0.1:8201 + Listener 1: tcp (addr: "127.0.0.1:8200", cluster address: "127.0.0.1:8201", tls: "disabled") + Log Level: info + Mlock: supported: false, enabled: false + Redirect Address: http://127.0.0.1:8200 + Storage: inmem + Version: Vault v0.8.3 + Version Sha: a393b20cb6d96c73e52eb5af776c892b8107a45d + +==> WARNING: Dev mode is enabled! + +In this mode, Vault is completely in-memory and unsealed. +Vault is configured to only have a single unseal key. The root +token has already been authenticated with the CLI, so you can +immediately begin using the Vault CLI. + +The only step you need to take is to set the following +environment variables: + + export VAULT_ADDR='http://127.0.0.1:8200' + +The unseal key and root token are reproduced below in case you +want to seal/unseal the Vault or play with authentication. + +Unseal Key: YzFfPgnLl9R1f6bLU7tGqi/PIDhDaAV/tlNDMV5Rrq0= +Root Token: f84b587e-5882-bba1-a3f0-d1a3d90ca105 +``` + +### Pre-requisites +- Nomad ACL system bootstrapped. +- A management token (the bootstrap token can be used, but for production + systems it's recommended to have a separate token) +- A set of policies created in Nomad +- An unsealed Vault server (Vault running in `dev` mode is unsealed + automatically upon startup) + - Vault must be version 0.9.3 or later to have the Nomad plugin + +### Configuration +Mount the [`nomad`][nomad_backend] secret backend in Vault: + +``` +$ vault mount nomad +Successfully mounted 'nomad' at 'nomad'! +``` + +Configure access with Nomad's address and management token: + +``` +$ vault write nomad/config/access \ + address=http://127.0.0.1:4646 \ + token=adf4238a-882b-9ddc-4a9d-5b6758e4159e +Success! Data written to: nomad/config/access +``` + +Vault secret backends have the concept of roles, which are configuration units that group one or more +Vault policies to a potential identity attribute, (e.g. LDAP Group membership). The name of the role +is specified on the path, while the mapping to policies is done by naming them in a comma separated list, +for example: + +``` +$ vault write nomad/role/role-name policy=policyone,policytwo +Success! Data written to: nomad/roles/role-name +``` + +Similarly, to create management tokens, or global tokens: + +``` +$ vault write nomad/role/role-name type=management global=true +Success! Data written to: nomad/roles/role-name +``` + +Create a Vault policy to allow different identities to get tokens associated with a particular +role: + +``` +$ echo 'path "nomad/creds/role-name" { + capabilities = ["read"] +}' | vault policy-write nomad-user-policy - +Policy 'nomad-user-policy' written. +``` + +If you have an existing authentication backend (like LDAP), follow the relevant instructions to create +a role available on the [Authentication backends page](https://www.vaultproject.io/docs/auth/index.html). +Otherwise, for testing purposes, a Vault token can be generated associated with the policy: + +``` +$ vault token-create -policy=nomad-user-policy +Key Value +--- ----- +token deedfa83-99b5-34a1-278d-e8fb76809a5b +token_accessor fd185371-7d80-8011-4f45-1bb3af2c2733 +token_duration 768h0m0s +token_renewable true +token_policies [default nomad-user-policy] +``` + +Finally obtain a Nomad Token using the existing Vault Token: + +``` +$ vault read nomad/creds/role-name +Key Value +--- ----- +lease_id nomad/creds/test/6fb22e25-0cd1-b4c9-494e-aba330c317b9 +lease_duration 768h0m0s +lease_renewable true +accessor_id 10b8fb49-7024-2126-8683-ab355b581db2 +secret_id 8898d19c-e5b3-35e4-649e-4153d63fbea9 +``` + +Verify that the token is created correctly in Nomad, looking it up by its accessor: + +``` +$ nomad acl token info 10b8fb49-7024-2126-8683-ab355b581db2 +Accessor ID = 10b8fb49-7024-2126-8683-ab355b581db2 +Secret ID = 8898d19c-e5b3-35e4-649e-4153d63fbea9 +Name = Vault test root 1507307164169530060 +Type = management +Global = true +Policies = n/a +Create Time = 2017-10-06 16:26:04.170633207 +0000 UTC +Create Index = 228 +Modify Index = 228 +``` + +Any user or process with access to Vault can now obtain short lived Nomad Tokens in order to +carry out operations, thus centralising the access to Nomad tokens. + + +[nomad_backend]: https://www.vaultproject.io/docs/secrets/nomad/index.html diff --git a/website/source/guides/cluster/autopilot.html.md b/website/source/guides/cluster/autopilot.html.md index 24e390cb0..15f650732 100644 --- a/website/source/guides/cluster/autopilot.html.md +++ b/website/source/guides/cluster/autopilot.html.md @@ -32,9 +32,9 @@ autopilot { last_contact_threshold = 200ms max_trailing_logs = 250 server_stabilization_time = "10s" - redundancy_zone_tag = "az" + enable_redundancy_zones = false disable_upgrade_migration = false - upgrade_version_tag = "" + enable_custom_upgrades = false } ``` @@ -49,21 +49,21 @@ CleanupDeadServers = true LastContactThreshold = 200ms MaxTrailingLogs = 250 ServerStabilizationTime = 10s -RedundancyZoneTag = "" +EnableRedundancyZones = false DisableUpgradeMigration = false -UpgradeVersionTag = "" +EnableCustomUpgrades = false -$ Nomad operator autopilot set-config -cleanup-dead-servers=false +$ nomad operator autopilot set-config -cleanup-dead-servers=false Configuration updated! -$ Nomad operator autopilot get-config +$ nomad operator autopilot get-config CleanupDeadServers = false LastContactThreshold = 200ms MaxTrailingLogs = 250 ServerStabilizationTime = 10s -RedundancyZoneTag = "" +EnableRedundancyZones = false DisableUpgradeMigration = false -UpgradeVersionTag = "" +EnableCustomUpgrades = false ``` ## Dead Server Cleanup @@ -164,15 +164,21 @@ isolated failure domains such as AWS Availability Zones; users would be forced t have an overly-large quorum (2-3 nodes per AZ) or give up redundancy within an AZ by deploying just one server in each. -If the `RedundancyZoneTag` setting is set, Nomad will use its value to look for a -zone in each server's specified [`-meta`](/docs/agent/configuration/client.html#meta) -tag. For example, if `RedundancyZoneTag` is set to `zone`, and `-meta zone=east1a` -is used when starting a server, that server's redundancy zone will be `east1a`. +If the `EnableRedundancyZones` setting is set, Nomad will use its value to look for a +zone in each server's specified [`redundancy_zone`] +(/docs/agent/configuration/server.html#redundancy_zone) field. Here's an example showing how to configure this: +```hcl +/* config.hcl */ +server { + redundancy_zone = "west-1" +} ``` -$ nomad operator autopilot set-config -redundancy-zone-tag=zone + +``` +$ nomad operator autopilot set-config -enable-redundancy-zones=true Configuration updated! ``` @@ -193,27 +199,25 @@ to voters and demoting the old servers. After this is finished, the old servers safely removed from the cluster. To check the Nomad version of the servers, either the [autopilot health] -(/api/operator.html#read-health) endpoint or the `Nomad members` +(/api/operator.html#read-health) endpoint or the `nomad members` command can be used: ``` -$ Nomad members -Node Address Status Type Build Protocol DC -node1 127.0.0.1:8301 alive server 0.7.5 2 dc1 -node2 127.0.0.1:8703 alive server 0.7.5 2 dc1 -node3 127.0.0.1:8803 alive server 0.7.5 2 dc1 -node4 127.0.0.1:8203 alive server 0.8.0 2 dc1 +$ nomad server-members +Name Address Port Status Leader Protocol Build Datacenter Region +node1 127.0.0.1 4648 alive true 3 0.7.1 dc1 global +node2 127.0.0.1 4748 alive false 3 0.7.1 dc1 global +node3 127.0.0.1 4848 alive false 3 0.7.1 dc1 global +node4 127.0.0.1 4948 alive false 3 0.8.0 dc1 global ``` ### Migrations Without a Nomad Version Change -The `UpgradeVersionTag` can be used to override the version information used during +The `EnableCustomUpgrades` field can be used to override the version information used during a migration, so that the migration logic can be used for updating the cluster when changing configuration. -If the `UpgradeVersionTag` setting is set, Nomad will use its value to look for a -version in each server's specified [`-meta`](/docs/agent/configuration/client.html#meta) -tag. For example, if `UpgradeVersionTag` is set to `build`, and `-meta build:0.0.2` -is used when starting a server, that server's version will be `0.0.2` when considered in -a migration. The upgrade logic will follow semantic versioning and the version string +If the `EnableCustomUpgrades` setting is set to `true`, Nomad will use its value to look for a +version in each server's specified [`upgrade_version`](/docs/agent/configuration/server.html#upgrade_version) +tag. The upgrade logic will follow semantic versioning and the `upgrade_version` must be in the form of either `X`, `X.Y`, or `X.Y.Z`.