* 'master' of https://github.com/hashicorp/nomad: (59 commits)
  Test coverage for the promote canary feature
  Update README.md
  launcher readme
  Add stats to launcher
  Add stats to example plugin
  Example device plugin and helpers
  Fix the flickering issue with start/stop job
  Add a confirmation loading state to the two-step-button component
  Switch stop/run job actions to EC tasks
  Test coverage for the Start Job behavior
  Add Start Job action on the job overview page for when a job is dead
  gofmt -s
  Update the info message about token storage
  Switch token storage to localStorage from sessionStorage
  add stats to device interface
  Change the latest deployment component to include a Promote Canary button
  Support the promote deployment api action
  Simplify the data control flow around job.plan()
  statistics protos
  Acceptance tests for the edit behaviors on the job definition page
  ...
This commit is contained in:
Nick Ethier 2018-08-31 14:46:20 -04:00
commit 3e82ad74f4
No known key found for this signature in database
GPG key ID: 07C1A3ECED90D24A
87 changed files with 4226 additions and 214 deletions

View file

@ -1,10 +1,13 @@
package base
import (
"bytes"
"context"
"reflect"
plugin "github.com/hashicorp/go-plugin"
"github.com/hashicorp/nomad/plugins/base/proto"
"github.com/ugorji/go/codec"
"google.golang.org/grpc"
)
@ -46,3 +49,15 @@ func (p *PluginBase) GRPCServer(broker *plugin.GRPCBroker, s *grpc.Server) error
func (p *PluginBase) GRPCClient(ctx context.Context, broker *plugin.GRPCBroker, c *grpc.ClientConn) (interface{}, error) {
return &BasePluginClient{Client: proto.NewBasePluginClient(c)}, nil
}
// MsgpackHandle is a shared handle for encoding/decoding of structs
var MsgpackHandle = func() *codec.MsgpackHandle {
h := &codec.MsgpackHandle{RawToString: true}
h.MapType = reflect.TypeOf(map[string]interface{}(nil))
return h
}()
// MsgPackDecode is used to decode a MsgPack encoded object
func MsgPackDecode(buf []byte, out interface{}) error {
return codec.NewDecoder(bytes.NewReader(buf), MsgpackHandle).Decode(out)
}

View file

@ -86,3 +86,53 @@ func (d *devicePluginClient) Reserve(deviceIDs []string) (*ContainerReservation,
out := convertProtoContainerReservation(resp.GetContainerRes())
return out, nil
}
// Stats is used to retrieve device statistics from the device plugin. An error
// may be immediately returned if the stats call could not be made or as part of
// the streaming response. If the context is cancelled, the error will be
// propogated.
func (d *devicePluginClient) Stats(ctx context.Context) (<-chan *StatsResponse, error) {
var req proto.StatsRequest
stream, err := d.client.Stats(ctx, &req)
if err != nil {
return nil, err
}
out := make(chan *StatsResponse, 1)
go d.handleStats(ctx, stream, out)
return out, nil
}
// handleStats should be launched in a goroutine and handles converting
// the gRPC stream to a channel. Exits either when context is cancelled or the
// stream has an error.
func (d *devicePluginClient) handleStats(
ctx netctx.Context,
stream proto.DevicePlugin_StatsClient,
out chan *StatsResponse) {
for {
resp, err := stream.Recv()
if err != nil {
// Handle a non-graceful stream error
if err != io.EOF {
if errStatus := status.FromContextError(ctx.Err()); errStatus.Code() == codes.Canceled {
err = context.Canceled
}
out <- &StatsResponse{
Error: err,
}
}
// End the stream
close(out)
return
}
// Send the response
out <- &StatsResponse{
Groups: convertProtoDeviceGroupsStats(resp.GetGroups()),
}
}
}

View file

@ -0,0 +1,26 @@
This package provides an example implementation of a device plugin for
reference.
# Behavior
The example device plugin models files within a specified directory as devices. The plugin will periodically scan the directory for changes and will expose them via the streaming Fingerprint RPC. Device health is set to unhealthy if the file has a specific filemode permission as described by the config `unhealthy_perm`. Further statistics are also collected on the detected devices.
# Config
The configuration should be passed via an HCL file that begins with a top level `config` stanza:
```
config {
dir = "/my/path/to/scan"
list_period = "1s"
stats_period = "5s"
unhealthy_perm = "-rw-rw-rw-"
}
```
The valid configuration options are:
* `dir` (`string`: `"."`): The directory to scan for files that will represent fake devices.
* `list_period` (`string`: `"5s"`): The interval to scan the directory for changes.
* `stats_period` (`string`: `"5s"`): The interval at which to emit statistics about the devices.
* `unhealthy_perm` (`string`: `"-rwxrwxrwx"`): The file mode permission that if set on a detected file will casue the device to be considered unhealthy.

View file

@ -0,0 +1,18 @@
package main
import (
log "github.com/hashicorp/go-hclog"
"github.com/hashicorp/nomad/plugins"
"github.com/hashicorp/nomad/plugins/device/cmd/example"
)
func main() {
// Serve the plugin
plugins.Serve(factory)
}
// factory returns a new instance of our example device plugin
func factory(log log.Logger) interface{} {
return example.NewExampleDevice(log)
}

View file

@ -0,0 +1,384 @@
package example
import (
"context"
"fmt"
"io/ioutil"
"os"
"path/filepath"
"sync"
"time"
log "github.com/hashicorp/go-hclog"
"github.com/kr/pretty"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"github.com/hashicorp/nomad/plugins/base"
"github.com/hashicorp/nomad/plugins/device"
"github.com/hashicorp/nomad/plugins/shared/hclspec"
)
const (
// pluginName is the name of the plugin
pluginName = "example-fs-device"
// vendor is the vendor providing the devices
vendor = "nomad"
// deviceType is the type of device being returned
deviceType = "file"
// deviceName is the name of the devices being exposed
deviceName = "mock"
)
var (
// pluginInfo describes the plugin
pluginInfo = &base.PluginInfoResponse{
Type: base.PluginTypeDevice,
PluginApiVersion: "0.0.1", // XXX This should be an array and should be consts
PluginVersion: "0.1.0",
Name: pluginName,
}
// configSpec is the specification of the plugin's configuration
configSpec = hclspec.NewObject(map[string]*hclspec.Spec{
"dir": hclspec.NewDefault(
hclspec.NewAttr("dir", "string", false),
hclspec.NewLiteral("\".\""),
),
"list_period": hclspec.NewDefault(
hclspec.NewAttr("list_period", "string", false),
hclspec.NewLiteral("\"5s\""),
),
"unhealthy_perm": hclspec.NewDefault(
hclspec.NewAttr("unhealthy_perm", "string", false),
hclspec.NewLiteral("\"-rwxrwxrwx\""),
),
"stats_period": hclspec.NewDefault(
hclspec.NewAttr("stats_period", "string", false),
hclspec.NewLiteral("\"5s\""),
),
})
)
// Config contains configuration information for the plugin.
type Config struct {
Dir string `codec:"dir"`
ListPeriod string `codec:"list_period"`
StatsPeriod string `codec:"stats_period"`
UnhealthyPerm string `codec:"unhealthy_perm"`
}
// FsDevice is an example device plugin. The device plugin exposes files as
// devices and periodically polls the directory for new files. If a file has a
// given file permission, it is considered unhealthy. This device plugin is
// purely for use as an example.
type FsDevice struct {
logger log.Logger
// deviceDir is the directory we expose as devices
deviceDir string
// unhealthyPerm is the permissions on a file we consider unhealthy
unhealthyPerm string
// listPeriod is how often we should list the device directory to detect new
// devices
listPeriod time.Duration
// statsPeriod is how often we should collect statistics for fingerprinted
// devices.
statsPeriod time.Duration
// devices is the set of detected devices and maps whether they are healthy
devices map[string]bool
deviceLock sync.RWMutex
}
// NewExampleDevice returns a new example device plugin.
func NewExampleDevice(log log.Logger) *FsDevice {
return &FsDevice{
logger: log.Named(pluginName),
devices: make(map[string]bool),
}
}
// PluginInfo returns information describing the plugin.
func (d *FsDevice) PluginInfo() (*base.PluginInfoResponse, error) {
return pluginInfo, nil
}
// ConfigSchema returns the plugins configuration schema.
func (d *FsDevice) ConfigSchema() (*hclspec.Spec, error) {
return configSpec, nil
}
// SetConfig is used to set the configuration of the plugin.
func (d *FsDevice) SetConfig(data []byte) error {
var config Config
if err := base.MsgPackDecode(data, &config); err != nil {
return err
}
// Save the device directory and the unhealthy permissions
d.deviceDir = config.Dir
d.unhealthyPerm = config.UnhealthyPerm
// Convert the poll period
period, err := time.ParseDuration(config.ListPeriod)
if err != nil {
return fmt.Errorf("failed to parse list period %q: %v", config.ListPeriod, err)
}
d.listPeriod = period
// Convert the stats period
speriod, err := time.ParseDuration(config.StatsPeriod)
if err != nil {
return fmt.Errorf("failed to parse list period %q: %v", config.StatsPeriod, err)
}
d.statsPeriod = speriod
d.logger.Debug("test debug")
d.logger.Info("config set", "config", log.Fmt("% #v", pretty.Formatter(config)))
return nil
}
// Fingerprint streams detected devices. If device changes are detected or the
// devices health changes, messages will be emitted.
func (d *FsDevice) Fingerprint(ctx context.Context) (<-chan *device.FingerprintResponse, error) {
if d.deviceDir == "" {
return nil, status.New(codes.Internal, "device directory not set in config").Err()
}
outCh := make(chan *device.FingerprintResponse)
go d.fingerprint(ctx, outCh)
return outCh, nil
}
// fingerprint is the long running goroutine that detects hardware
func (d *FsDevice) fingerprint(ctx context.Context, devices chan *device.FingerprintResponse) {
defer close(devices)
// Create a timer that will fire immediately for the first detection
ticker := time.NewTimer(0)
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
ticker.Reset(d.listPeriod)
}
d.logger.Trace("scanning for changes")
files, err := ioutil.ReadDir(d.deviceDir)
if err != nil {
d.logger.Error("failed to list device directory", "error", err)
devices <- device.NewFingerprintError(err)
return
}
detected := d.diffFiles(files)
if len(detected) == 0 {
continue
}
devices <- device.NewFingerprint(getDeviceGroup(detected))
}
}
func (d *FsDevice) diffFiles(files []os.FileInfo) []*device.Device {
d.deviceLock.Lock()
defer d.deviceLock.Unlock()
// Build an unhealthy message
unhealthyDesc := fmt.Sprintf("Device has bad permissions %q", d.unhealthyPerm)
var changes bool
fnames := make(map[string]struct{})
for _, f := range files {
name := f.Name()
fnames[name] = struct{}{}
if f.IsDir() {
d.logger.Trace("skipping directory", "directory", name)
continue
}
// Determine the health
perms := f.Mode().Perm().String()
healthy := perms != d.unhealthyPerm
d.logger.Trace("checking health", "file perm", perms, "unhealthy perms", d.unhealthyPerm, "healthy", healthy)
// See if we alreay have the device
oldHealth, ok := d.devices[name]
if ok && oldHealth == healthy {
continue
}
// Health has changed or we have a new object
changes = true
d.devices[name] = healthy
}
for id := range d.devices {
if _, ok := fnames[id]; !ok {
delete(d.devices, id)
changes = true
}
}
// Nothing to do
if !changes {
return nil
}
// Build the devices
detected := make([]*device.Device, 0, len(d.devices))
for name, healthy := range d.devices {
var desc string
if !healthy {
desc = unhealthyDesc
}
detected = append(detected, &device.Device{
ID: name,
Healthy: healthy,
HealthDesc: desc,
})
}
return detected
}
// getDeviceGroup is a helper to build the DeviceGroup given a set of devices.
func getDeviceGroup(devices []*device.Device) *device.DeviceGroup {
return &device.DeviceGroup{
Vendor: vendor,
Type: deviceType,
Name: deviceName,
Devices: devices,
}
}
// Reserve returns information on how to mount the given devices.
func (d *FsDevice) Reserve(deviceIDs []string) (*device.ContainerReservation, error) {
if len(deviceIDs) == 0 {
return nil, status.New(codes.InvalidArgument, "no device ids given").Err()
}
resp := &device.ContainerReservation{}
for _, id := range deviceIDs {
// Check if the device is known
if _, ok := d.devices[id]; !ok {
return nil, status.Newf(codes.InvalidArgument, "unknown device %q", id).Err()
}
// Add a mount
resp.Devices = append(resp.Devices, &device.DeviceSpec{
TaskPath: fmt.Sprintf("/dev/%s", id),
HostPath: filepath.Join(d.deviceDir, id),
CgroupPerms: "rw",
})
}
return resp, nil
}
// Stats streams statistics for the detected devices.
func (d *FsDevice) Stats(ctx context.Context) (<-chan *device.StatsResponse, error) {
outCh := make(chan *device.StatsResponse)
go d.stats(ctx, outCh)
return outCh, nil
}
// stats is the long running goroutine that streams device statistics
func (d *FsDevice) stats(ctx context.Context, stats chan *device.StatsResponse) {
defer close(stats)
// Create a timer that will fire immediately for the first detection
ticker := time.NewTimer(0)
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
ticker.Reset(d.listPeriod)
}
deviceStats, err := d.collectStats()
if err != nil {
stats <- &device.StatsResponse{
Error: err,
}
return
}
if deviceStats == nil {
continue
}
stats <- &device.StatsResponse{
Groups: []*device.DeviceGroupStats{deviceStats},
}
}
}
func (d *FsDevice) collectStats() (*device.DeviceGroupStats, error) {
d.deviceLock.RLock()
defer d.deviceLock.RUnlock()
l := len(d.devices)
if l == 0 {
return nil, nil
}
now := time.Now()
group := &device.DeviceGroupStats{
Vendor: vendor,
Type: deviceType,
Name: deviceName,
InstanceStats: make(map[string]*device.DeviceStats, l),
}
for k := range d.devices {
p := filepath.Join(d.deviceDir, k)
f, err := os.Stat(p)
if err != nil {
return nil, fmt.Errorf("failed to stat %q: %v", p, err)
}
s := &device.DeviceStats{
Summary: &device.StatValue{
IntNumeratorVal: f.Size(),
Unit: "bytes",
Desc: "Filesize in bytes",
},
Stats: &device.StatObject{
Attributes: map[string]*device.StatValue{
"size": {
IntNumeratorVal: f.Size(),
Unit: "bytes",
Desc: "Filesize in bytes",
},
"modify_time": {
StringVal: f.ModTime().String(),
Desc: "Last modified",
},
"mode": {
StringVal: f.Mode().String(),
Desc: "File mode",
},
},
},
Timestamp: now,
}
group.InstanceStats[k] = s
}
return group, nil
}

View file

@ -2,6 +2,7 @@ package device
import (
"context"
"time"
"github.com/hashicorp/nomad/plugins/base"
)
@ -22,6 +23,9 @@ type DevicePlugin interface {
// Reserve is used to reserve a set of devices and retrieve mount
// instructions.
Reserve(deviceIDs []string) (*ContainerReservation, error)
// Stats returns a stream of statistics per device.
Stats(ctx context.Context) (<-chan *StatsResponse, error)
}
// FingerprintResponse includes a set of detected devices or an error in the
@ -34,6 +38,21 @@ type FingerprintResponse struct {
Error error
}
// NewFingerprint takes a set of device groups and returns a fingerprint
// response
func NewFingerprint(devices ...*DeviceGroup) *FingerprintResponse {
return &FingerprintResponse{
Devices: devices,
}
}
// NewFingerprintError takes an error and returns a fingerprint response
func NewFingerprintError(err error) *FingerprintResponse {
return &FingerprintResponse{
Error: err,
}
}
// DeviceGroup is a grouping of devices that share a common vendor, device type
// and name.
type DeviceGroup struct {
@ -112,3 +131,73 @@ type DeviceSpec struct {
// CgroupPerms defines the permissions to use when mounting the device.
CgroupPerms string
}
// StatsResponse returns statistics for each device group.
type StatsResponse struct {
// Groups contains statistics for each device group.
Groups []*DeviceGroupStats
// Error is populated when collecting statistics has failed.
Error error
}
// DeviceGroupStats contains statistics for each device of a particular
// device group, identified by the vendor, type and name of the device.
type DeviceGroupStats struct {
Vendor string
Type string
Name string
// InstanceStats is a mapping of each device ID to its statistics.
InstanceStats map[string]*DeviceStats
}
// DeviceStats is the statistics for an individual device
type DeviceStats struct {
// Summary exposes a single summary metric that should be the most
// informative to users.
Summary *StatValue
// Stats contains the verbose statistics for the device.
Stats *StatObject
// Timestamp is the time the statistics were collected.
Timestamp time.Time
}
// StatObject is a collection of statistics either exposed at the top
// level or via nested StatObjects.
type StatObject struct {
// Nested is a mapping of object name to a nested stats object.
Nested map[string]*StatObject
// Attributes is a mapping of statistic name to its value.
Attributes map[string]*StatValue
}
// StatValue exposes the values of a particular statistic. The value may be of
// type float, integer, string or boolean. Numeric types can be exposed as a
// single value or as a fraction.
type StatValue struct {
// FloatNumeratorVal exposes a floating point value. If denominator is set
// it is assumed to be a fractional value, otherwise it is a scalar.
FloatNumeratorVal float64
FloatDenominatorVal float64
// IntNumeratorVal exposes a int value. If denominator is set it is assumed
// to be a fractional value, otherwise it is a scalar.
IntNumeratorVal int64
IntDenominatorVal int64
// StringVal exposes a string value. These are likely annotations.
StringVal string
// BoolVal exposes a boolean statistic.
BoolVal bool
// Unit gives the unit type: °F, %, MHz, MB, etc.
Unit string
// Desc provides a human readable description of the statistic.
Desc string
}

View file

@ -13,11 +13,17 @@ type MockDevicePlugin struct {
*base.MockPlugin
FingerprintF func(context.Context) (<-chan *FingerprintResponse, error)
ReserveF func([]string) (*ContainerReservation, error)
StatsF func(context.Context) (<-chan *StatsResponse, error)
}
func (p *MockDevicePlugin) Fingerprint(ctx context.Context) (<-chan *FingerprintResponse, error) {
return p.FingerprintF(ctx)
}
func (p *MockDevicePlugin) Reserve(devices []string) (*ContainerReservation, error) {
return p.ReserveF(devices)
}
func (p *MockDevicePlugin) Stats(ctx context.Context) (<-chan *StatsResponse, error) {
return p.StatsF(ctx)
}

View file

@ -3,6 +3,7 @@ package device
import (
"context"
log "github.com/hashicorp/go-hclog"
plugin "github.com/hashicorp/go-plugin"
"github.com/hashicorp/nomad/plugins/base"
bproto "github.com/hashicorp/nomad/plugins/base/proto"
@ -33,3 +34,16 @@ func (p *PluginDevice) GRPCClient(ctx context.Context, broker *plugin.GRPCBroker
},
}, nil
}
// Serve is used to serve a device plugin
func Serve(dev DevicePlugin, logger log.Logger) {
plugin.Serve(&plugin.ServeConfig{
HandshakeConfig: base.Handshake,
Plugins: map[string]plugin.Plugin{
base.PluginTypeBase: &base.PluginBase{Impl: dev},
base.PluginTypeDevice: &PluginDevice{Impl: dev},
},
GRPCServer: plugin.DefaultGRPCServer,
Logger: logger,
})
}

View file

@ -443,3 +443,264 @@ func TestDevicePlugin_Reserve(t *testing.T) {
require.EqualValues(req, received)
require.EqualValues(reservation, containerRes)
}
func TestDevicePlugin_Stats(t *testing.T) {
t.Parallel()
require := require.New(t)
devices1 := []*DeviceGroupStats{
{
Vendor: "nvidia",
Type: DeviceTypeGPU,
Name: "foo",
InstanceStats: map[string]*DeviceStats{
"1": {
Summary: &StatValue{
IntNumeratorVal: 10,
IntDenominatorVal: 20,
Unit: "MB",
Desc: "Unit test",
},
},
},
},
}
devices2 := []*DeviceGroupStats{
{
Vendor: "nvidia",
Type: DeviceTypeGPU,
Name: "foo",
InstanceStats: map[string]*DeviceStats{
"1": {
Summary: &StatValue{
FloatNumeratorVal: 10.0,
FloatDenominatorVal: 20.0,
Unit: "MB",
Desc: "Unit test",
},
},
},
},
{
Vendor: "nvidia",
Type: DeviceTypeGPU,
Name: "bar",
InstanceStats: map[string]*DeviceStats{
"1": {
Summary: &StatValue{
StringVal: "foo",
Unit: "MB",
Desc: "Unit test",
},
},
},
},
{
Vendor: "nvidia",
Type: DeviceTypeGPU,
Name: "baz",
InstanceStats: map[string]*DeviceStats{
"1": {
Summary: &StatValue{
BoolVal: true,
Unit: "MB",
Desc: "Unit test",
},
},
},
},
}
mock := &MockDevicePlugin{
StatsF: func(ctx context.Context) (<-chan *StatsResponse, error) {
outCh := make(chan *StatsResponse, 1)
go func() {
// Send two messages
for _, devs := range [][]*DeviceGroupStats{devices1, devices2} {
select {
case <-ctx.Done():
return
case outCh <- &StatsResponse{Groups: devs}:
}
}
close(outCh)
return
}()
return outCh, nil
},
}
client, server := plugin.TestPluginGRPCConn(t, map[string]plugin.Plugin{
base.PluginTypeBase: &base.PluginBase{Impl: mock},
base.PluginTypeDevice: &PluginDevice{Impl: mock},
})
defer server.Stop()
defer client.Close()
raw, err := client.Dispense(base.PluginTypeDevice)
if err != nil {
t.Fatalf("err: %s", err)
}
impl, ok := raw.(DevicePlugin)
if !ok {
t.Fatalf("bad: %#v", raw)
}
// Create a context
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// Get the stream
stream, err := impl.Stats(ctx)
require.NoError(err)
// Get the first message
var first *StatsResponse
select {
case <-time.After(1 * time.Second):
t.Fatal("timeout")
case first = <-stream:
}
require.NoError(first.Error)
require.EqualValues(devices1, first.Groups)
// Get the second message
var second *StatsResponse
select {
case <-time.After(1 * time.Second):
t.Fatal("timeout")
case second = <-stream:
}
require.NoError(second.Error)
require.EqualValues(devices2, second.Groups)
select {
case _, ok := <-stream:
require.False(ok)
case <-time.After(1 * time.Second):
t.Fatal("stream should be closed")
}
}
func TestDevicePlugin_Stats_StreamErr(t *testing.T) {
t.Parallel()
require := require.New(t)
ferr := fmt.Errorf("mock stats failed")
mock := &MockDevicePlugin{
StatsF: func(ctx context.Context) (<-chan *StatsResponse, error) {
outCh := make(chan *StatsResponse, 1)
go func() {
// Send the error
select {
case <-ctx.Done():
return
case outCh <- &StatsResponse{Error: ferr}:
}
close(outCh)
return
}()
return outCh, nil
},
}
client, server := plugin.TestPluginGRPCConn(t, map[string]plugin.Plugin{
base.PluginTypeBase: &base.PluginBase{Impl: mock},
base.PluginTypeDevice: &PluginDevice{Impl: mock},
})
defer server.Stop()
defer client.Close()
raw, err := client.Dispense(base.PluginTypeDevice)
if err != nil {
t.Fatalf("err: %s", err)
}
impl, ok := raw.(DevicePlugin)
if !ok {
t.Fatalf("bad: %#v", raw)
}
// Create a context
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// Get the stream
stream, err := impl.Stats(ctx)
require.NoError(err)
// Get the first message
var first *StatsResponse
select {
case <-time.After(1 * time.Second):
t.Fatal("timeout")
case first = <-stream:
}
errStatus := status.Convert(ferr)
require.EqualError(first.Error, errStatus.Err().Error())
}
func TestDevicePlugin_Stats_CancelCtx(t *testing.T) {
t.Parallel()
require := require.New(t)
mock := &MockDevicePlugin{
StatsF: func(ctx context.Context) (<-chan *StatsResponse, error) {
outCh := make(chan *StatsResponse, 1)
go func() {
<-ctx.Done()
close(outCh)
return
}()
return outCh, nil
},
}
client, server := plugin.TestPluginGRPCConn(t, map[string]plugin.Plugin{
base.PluginTypeBase: &base.PluginBase{Impl: mock},
base.PluginTypeDevice: &PluginDevice{Impl: mock},
})
defer server.Stop()
defer client.Close()
raw, err := client.Dispense(base.PluginTypeDevice)
if err != nil {
t.Fatalf("err: %s", err)
}
impl, ok := raw.(DevicePlugin)
if !ok {
t.Fatalf("bad: %#v", raw)
}
// Create a context
ctx, cancel := context.WithCancel(context.Background())
// Get the stream
stream, err := impl.Stats(ctx)
require.NoError(err)
// Get the first message
select {
case <-time.After(testutil.Timeout(10 * time.Millisecond)):
case _ = <-stream:
t.Fatal("bad value")
}
// Cancel the context
cancel()
// Make sure we are done
select {
case <-time.After(100 * time.Millisecond):
t.Fatalf("timeout")
case v := <-stream:
require.Error(v.Error)
require.EqualError(v.Error, context.Canceled.Error())
}
}

View file

@ -6,6 +6,7 @@ package proto
import proto "github.com/golang/protobuf/proto"
import fmt "fmt"
import math "math"
import timestamp "github.com/golang/protobuf/ptypes/timestamp"
import (
context "golang.org/x/net/context"
@ -34,7 +35,7 @@ func (m *FingerprintRequest) Reset() { *m = FingerprintRequest{} }
func (m *FingerprintRequest) String() string { return proto.CompactTextString(m) }
func (*FingerprintRequest) ProtoMessage() {}
func (*FingerprintRequest) Descriptor() ([]byte, []int) {
return fileDescriptor_device_7496b084f8b5ea81, []int{0}
return fileDescriptor_device_ebefe60214c28313, []int{0}
}
func (m *FingerprintRequest) XXX_Unmarshal(b []byte) error {
return xxx_messageInfo_FingerprintRequest.Unmarshal(m, b)
@ -69,7 +70,7 @@ func (m *FingerprintResponse) Reset() { *m = FingerprintResponse{} }
func (m *FingerprintResponse) String() string { return proto.CompactTextString(m) }
func (*FingerprintResponse) ProtoMessage() {}
func (*FingerprintResponse) Descriptor() ([]byte, []int) {
return fileDescriptor_device_7496b084f8b5ea81, []int{1}
return fileDescriptor_device_ebefe60214c28313, []int{1}
}
func (m *FingerprintResponse) XXX_Unmarshal(b []byte) error {
return xxx_messageInfo_FingerprintResponse.Unmarshal(m, b)
@ -118,7 +119,7 @@ func (m *DeviceGroup) Reset() { *m = DeviceGroup{} }
func (m *DeviceGroup) String() string { return proto.CompactTextString(m) }
func (*DeviceGroup) ProtoMessage() {}
func (*DeviceGroup) Descriptor() ([]byte, []int) {
return fileDescriptor_device_7496b084f8b5ea81, []int{2}
return fileDescriptor_device_ebefe60214c28313, []int{2}
}
func (m *DeviceGroup) XXX_Unmarshal(b []byte) error {
return xxx_messageInfo_DeviceGroup.Unmarshal(m, b)
@ -195,7 +196,7 @@ func (m *DetectedDevice) Reset() { *m = DetectedDevice{} }
func (m *DetectedDevice) String() string { return proto.CompactTextString(m) }
func (*DetectedDevice) ProtoMessage() {}
func (*DetectedDevice) Descriptor() ([]byte, []int) {
return fileDescriptor_device_7496b084f8b5ea81, []int{3}
return fileDescriptor_device_ebefe60214c28313, []int{3}
}
func (m *DetectedDevice) XXX_Unmarshal(b []byte) error {
return xxx_messageInfo_DetectedDevice.Unmarshal(m, b)
@ -257,7 +258,7 @@ func (m *DeviceLocality) Reset() { *m = DeviceLocality{} }
func (m *DeviceLocality) String() string { return proto.CompactTextString(m) }
func (*DeviceLocality) ProtoMessage() {}
func (*DeviceLocality) Descriptor() ([]byte, []int) {
return fileDescriptor_device_7496b084f8b5ea81, []int{4}
return fileDescriptor_device_ebefe60214c28313, []int{4}
}
func (m *DeviceLocality) XXX_Unmarshal(b []byte) error {
return xxx_messageInfo_DeviceLocality.Unmarshal(m, b)
@ -298,7 +299,7 @@ func (m *ReserveRequest) Reset() { *m = ReserveRequest{} }
func (m *ReserveRequest) String() string { return proto.CompactTextString(m) }
func (*ReserveRequest) ProtoMessage() {}
func (*ReserveRequest) Descriptor() ([]byte, []int) {
return fileDescriptor_device_7496b084f8b5ea81, []int{5}
return fileDescriptor_device_ebefe60214c28313, []int{5}
}
func (m *ReserveRequest) XXX_Unmarshal(b []byte) error {
return xxx_messageInfo_ReserveRequest.Unmarshal(m, b)
@ -341,7 +342,7 @@ func (m *ReserveResponse) Reset() { *m = ReserveResponse{} }
func (m *ReserveResponse) String() string { return proto.CompactTextString(m) }
func (*ReserveResponse) ProtoMessage() {}
func (*ReserveResponse) Descriptor() ([]byte, []int) {
return fileDescriptor_device_7496b084f8b5ea81, []int{6}
return fileDescriptor_device_ebefe60214c28313, []int{6}
}
func (m *ReserveResponse) XXX_Unmarshal(b []byte) error {
return xxx_messageInfo_ReserveResponse.Unmarshal(m, b)
@ -386,7 +387,7 @@ func (m *ContainerReservation) Reset() { *m = ContainerReservation{} }
func (m *ContainerReservation) String() string { return proto.CompactTextString(m) }
func (*ContainerReservation) ProtoMessage() {}
func (*ContainerReservation) Descriptor() ([]byte, []int) {
return fileDescriptor_device_7496b084f8b5ea81, []int{7}
return fileDescriptor_device_ebefe60214c28313, []int{7}
}
func (m *ContainerReservation) XXX_Unmarshal(b []byte) error {
return xxx_messageInfo_ContainerReservation.Unmarshal(m, b)
@ -445,7 +446,7 @@ func (m *Mount) Reset() { *m = Mount{} }
func (m *Mount) String() string { return proto.CompactTextString(m) }
func (*Mount) ProtoMessage() {}
func (*Mount) Descriptor() ([]byte, []int) {
return fileDescriptor_device_7496b084f8b5ea81, []int{8}
return fileDescriptor_device_ebefe60214c28313, []int{8}
}
func (m *Mount) XXX_Unmarshal(b []byte) error {
return xxx_messageInfo_Mount.Unmarshal(m, b)
@ -506,7 +507,7 @@ func (m *DeviceSpec) Reset() { *m = DeviceSpec{} }
func (m *DeviceSpec) String() string { return proto.CompactTextString(m) }
func (*DeviceSpec) ProtoMessage() {}
func (*DeviceSpec) Descriptor() ([]byte, []int) {
return fileDescriptor_device_7496b084f8b5ea81, []int{9}
return fileDescriptor_device_ebefe60214c28313, []int{9}
}
func (m *DeviceSpec) XXX_Unmarshal(b []byte) error {
return xxx_messageInfo_DeviceSpec.Unmarshal(m, b)
@ -547,6 +548,358 @@ func (m *DeviceSpec) GetPermissions() string {
return ""
}
// StatsRequest is used to parameterize the retrieval of statistics.
type StatsRequest struct {
XXX_NoUnkeyedLiteral struct{} `json:"-"`
XXX_unrecognized []byte `json:"-"`
XXX_sizecache int32 `json:"-"`
}
func (m *StatsRequest) Reset() { *m = StatsRequest{} }
func (m *StatsRequest) String() string { return proto.CompactTextString(m) }
func (*StatsRequest) ProtoMessage() {}
func (*StatsRequest) Descriptor() ([]byte, []int) {
return fileDescriptor_device_ebefe60214c28313, []int{10}
}
func (m *StatsRequest) XXX_Unmarshal(b []byte) error {
return xxx_messageInfo_StatsRequest.Unmarshal(m, b)
}
func (m *StatsRequest) XXX_Marshal(b []byte, deterministic bool) ([]byte, error) {
return xxx_messageInfo_StatsRequest.Marshal(b, m, deterministic)
}
func (dst *StatsRequest) XXX_Merge(src proto.Message) {
xxx_messageInfo_StatsRequest.Merge(dst, src)
}
func (m *StatsRequest) XXX_Size() int {
return xxx_messageInfo_StatsRequest.Size(m)
}
func (m *StatsRequest) XXX_DiscardUnknown() {
xxx_messageInfo_StatsRequest.DiscardUnknown(m)
}
var xxx_messageInfo_StatsRequest proto.InternalMessageInfo
// StatsResponse returns the statistics for each device group.
type StatsResponse struct {
// groups contains statistics for each device group.
Groups []*DeviceGroupStats `protobuf:"bytes,1,rep,name=groups,proto3" json:"groups,omitempty"`
XXX_NoUnkeyedLiteral struct{} `json:"-"`
XXX_unrecognized []byte `json:"-"`
XXX_sizecache int32 `json:"-"`
}
func (m *StatsResponse) Reset() { *m = StatsResponse{} }
func (m *StatsResponse) String() string { return proto.CompactTextString(m) }
func (*StatsResponse) ProtoMessage() {}
func (*StatsResponse) Descriptor() ([]byte, []int) {
return fileDescriptor_device_ebefe60214c28313, []int{11}
}
func (m *StatsResponse) XXX_Unmarshal(b []byte) error {
return xxx_messageInfo_StatsResponse.Unmarshal(m, b)
}
func (m *StatsResponse) XXX_Marshal(b []byte, deterministic bool) ([]byte, error) {
return xxx_messageInfo_StatsResponse.Marshal(b, m, deterministic)
}
func (dst *StatsResponse) XXX_Merge(src proto.Message) {
xxx_messageInfo_StatsResponse.Merge(dst, src)
}
func (m *StatsResponse) XXX_Size() int {
return xxx_messageInfo_StatsResponse.Size(m)
}
func (m *StatsResponse) XXX_DiscardUnknown() {
xxx_messageInfo_StatsResponse.DiscardUnknown(m)
}
var xxx_messageInfo_StatsResponse proto.InternalMessageInfo
func (m *StatsResponse) GetGroups() []*DeviceGroupStats {
if m != nil {
return m.Groups
}
return nil
}
// DeviceGroupStats contains statistics for each device of a particular
// device group, identified by the vendor, type and name of the device.
type DeviceGroupStats struct {
Vendor string `protobuf:"bytes,1,opt,name=vendor,proto3" json:"vendor,omitempty"`
Type string `protobuf:"bytes,2,opt,name=type,proto3" json:"type,omitempty"`
Name string `protobuf:"bytes,3,opt,name=name,proto3" json:"name,omitempty"`
// instance_stats is a mapping of each device ID to its statistics.
InstanceStats map[string]*DeviceStats `protobuf:"bytes,4,rep,name=instance_stats,json=instanceStats,proto3" json:"instance_stats,omitempty" protobuf_key:"bytes,1,opt,name=key,proto3" protobuf_val:"bytes,2,opt,name=value,proto3"`
XXX_NoUnkeyedLiteral struct{} `json:"-"`
XXX_unrecognized []byte `json:"-"`
XXX_sizecache int32 `json:"-"`
}
func (m *DeviceGroupStats) Reset() { *m = DeviceGroupStats{} }
func (m *DeviceGroupStats) String() string { return proto.CompactTextString(m) }
func (*DeviceGroupStats) ProtoMessage() {}
func (*DeviceGroupStats) Descriptor() ([]byte, []int) {
return fileDescriptor_device_ebefe60214c28313, []int{12}
}
func (m *DeviceGroupStats) XXX_Unmarshal(b []byte) error {
return xxx_messageInfo_DeviceGroupStats.Unmarshal(m, b)
}
func (m *DeviceGroupStats) XXX_Marshal(b []byte, deterministic bool) ([]byte, error) {
return xxx_messageInfo_DeviceGroupStats.Marshal(b, m, deterministic)
}
func (dst *DeviceGroupStats) XXX_Merge(src proto.Message) {
xxx_messageInfo_DeviceGroupStats.Merge(dst, src)
}
func (m *DeviceGroupStats) XXX_Size() int {
return xxx_messageInfo_DeviceGroupStats.Size(m)
}
func (m *DeviceGroupStats) XXX_DiscardUnknown() {
xxx_messageInfo_DeviceGroupStats.DiscardUnknown(m)
}
var xxx_messageInfo_DeviceGroupStats proto.InternalMessageInfo
func (m *DeviceGroupStats) GetVendor() string {
if m != nil {
return m.Vendor
}
return ""
}
func (m *DeviceGroupStats) GetType() string {
if m != nil {
return m.Type
}
return ""
}
func (m *DeviceGroupStats) GetName() string {
if m != nil {
return m.Name
}
return ""
}
func (m *DeviceGroupStats) GetInstanceStats() map[string]*DeviceStats {
if m != nil {
return m.InstanceStats
}
return nil
}
// DeviceStats is the statistics for an individual device
type DeviceStats struct {
// summary exposes a single summary metric that should be the most
// informative to users.
Summary *StatValue `protobuf:"bytes,1,opt,name=summary,proto3" json:"summary,omitempty"`
// stats contains the verbose statistics for the device.
Stats *StatObject `protobuf:"bytes,2,opt,name=stats,proto3" json:"stats,omitempty"`
// timestamp is the time the statistics were collected.
Timestamp *timestamp.Timestamp `protobuf:"bytes,3,opt,name=timestamp,proto3" json:"timestamp,omitempty"`
XXX_NoUnkeyedLiteral struct{} `json:"-"`
XXX_unrecognized []byte `json:"-"`
XXX_sizecache int32 `json:"-"`
}
func (m *DeviceStats) Reset() { *m = DeviceStats{} }
func (m *DeviceStats) String() string { return proto.CompactTextString(m) }
func (*DeviceStats) ProtoMessage() {}
func (*DeviceStats) Descriptor() ([]byte, []int) {
return fileDescriptor_device_ebefe60214c28313, []int{13}
}
func (m *DeviceStats) XXX_Unmarshal(b []byte) error {
return xxx_messageInfo_DeviceStats.Unmarshal(m, b)
}
func (m *DeviceStats) XXX_Marshal(b []byte, deterministic bool) ([]byte, error) {
return xxx_messageInfo_DeviceStats.Marshal(b, m, deterministic)
}
func (dst *DeviceStats) XXX_Merge(src proto.Message) {
xxx_messageInfo_DeviceStats.Merge(dst, src)
}
func (m *DeviceStats) XXX_Size() int {
return xxx_messageInfo_DeviceStats.Size(m)
}
func (m *DeviceStats) XXX_DiscardUnknown() {
xxx_messageInfo_DeviceStats.DiscardUnknown(m)
}
var xxx_messageInfo_DeviceStats proto.InternalMessageInfo
func (m *DeviceStats) GetSummary() *StatValue {
if m != nil {
return m.Summary
}
return nil
}
func (m *DeviceStats) GetStats() *StatObject {
if m != nil {
return m.Stats
}
return nil
}
func (m *DeviceStats) GetTimestamp() *timestamp.Timestamp {
if m != nil {
return m.Timestamp
}
return nil
}
// StatObject is a collection of statistics either exposed at the top
// level or via nested StatObjects.
type StatObject struct {
// nested is a mapping of object name to a nested stats object.
Nested map[string]*StatObject `protobuf:"bytes,1,rep,name=nested,proto3" json:"nested,omitempty" protobuf_key:"bytes,1,opt,name=key,proto3" protobuf_val:"bytes,2,opt,name=value,proto3"`
// attributes is a mapping of statistic name to its value.
Attributes map[string]*StatValue `protobuf:"bytes,2,rep,name=attributes,proto3" json:"attributes,omitempty" protobuf_key:"bytes,1,opt,name=key,proto3" protobuf_val:"bytes,2,opt,name=value,proto3"`
XXX_NoUnkeyedLiteral struct{} `json:"-"`
XXX_unrecognized []byte `json:"-"`
XXX_sizecache int32 `json:"-"`
}
func (m *StatObject) Reset() { *m = StatObject{} }
func (m *StatObject) String() string { return proto.CompactTextString(m) }
func (*StatObject) ProtoMessage() {}
func (*StatObject) Descriptor() ([]byte, []int) {
return fileDescriptor_device_ebefe60214c28313, []int{14}
}
func (m *StatObject) XXX_Unmarshal(b []byte) error {
return xxx_messageInfo_StatObject.Unmarshal(m, b)
}
func (m *StatObject) XXX_Marshal(b []byte, deterministic bool) ([]byte, error) {
return xxx_messageInfo_StatObject.Marshal(b, m, deterministic)
}
func (dst *StatObject) XXX_Merge(src proto.Message) {
xxx_messageInfo_StatObject.Merge(dst, src)
}
func (m *StatObject) XXX_Size() int {
return xxx_messageInfo_StatObject.Size(m)
}
func (m *StatObject) XXX_DiscardUnknown() {
xxx_messageInfo_StatObject.DiscardUnknown(m)
}
var xxx_messageInfo_StatObject proto.InternalMessageInfo
func (m *StatObject) GetNested() map[string]*StatObject {
if m != nil {
return m.Nested
}
return nil
}
func (m *StatObject) GetAttributes() map[string]*StatValue {
if m != nil {
return m.Attributes
}
return nil
}
// StatValue exposes the values of a particular statistic. The value may
// be of type double, integer, string or boolean. Numeric types can be
// exposed as a single value or as a fraction.
type StatValue struct {
// float_numerator_val exposes a floating point value. If denominator
// is set it is assumed to be a fractional value, otherwise it is a
// scalar.
FloatNumeratorVal float64 `protobuf:"fixed64,1,opt,name=float_numerator_val,json=floatNumeratorVal,proto3" json:"float_numerator_val,omitempty"`
FloatDenominatorVal float64 `protobuf:"fixed64,2,opt,name=float_denominator_val,json=floatDenominatorVal,proto3" json:"float_denominator_val,omitempty"`
// int_numerator_val exposes a int value. If denominator
// is set it is assumed to be a fractional value, otherwise it is a
// scalar.
IntNumeratorVal int64 `protobuf:"varint,3,opt,name=int_numerator_val,json=intNumeratorVal,proto3" json:"int_numerator_val,omitempty"`
IntDenominatorVal int64 `protobuf:"varint,4,opt,name=int_denominator_val,json=intDenominatorVal,proto3" json:"int_denominator_val,omitempty"`
// string_val exposes a string value. These are likely annotations.
StringVal string `protobuf:"bytes,5,opt,name=string_val,json=stringVal,proto3" json:"string_val,omitempty"`
// bool_val exposes a boolean statistic.
BoolVal bool `protobuf:"varint,6,opt,name=bool_val,json=boolVal,proto3" json:"bool_val,omitempty"`
// unit gives the unit type: °F, %, MHz, MB, etc.
Unit string `protobuf:"bytes,7,opt,name=unit,proto3" json:"unit,omitempty"`
// desc provides a human readable description of the statistic.
Desc string `protobuf:"bytes,8,opt,name=desc,proto3" json:"desc,omitempty"`
XXX_NoUnkeyedLiteral struct{} `json:"-"`
XXX_unrecognized []byte `json:"-"`
XXX_sizecache int32 `json:"-"`
}
func (m *StatValue) Reset() { *m = StatValue{} }
func (m *StatValue) String() string { return proto.CompactTextString(m) }
func (*StatValue) ProtoMessage() {}
func (*StatValue) Descriptor() ([]byte, []int) {
return fileDescriptor_device_ebefe60214c28313, []int{15}
}
func (m *StatValue) XXX_Unmarshal(b []byte) error {
return xxx_messageInfo_StatValue.Unmarshal(m, b)
}
func (m *StatValue) XXX_Marshal(b []byte, deterministic bool) ([]byte, error) {
return xxx_messageInfo_StatValue.Marshal(b, m, deterministic)
}
func (dst *StatValue) XXX_Merge(src proto.Message) {
xxx_messageInfo_StatValue.Merge(dst, src)
}
func (m *StatValue) XXX_Size() int {
return xxx_messageInfo_StatValue.Size(m)
}
func (m *StatValue) XXX_DiscardUnknown() {
xxx_messageInfo_StatValue.DiscardUnknown(m)
}
var xxx_messageInfo_StatValue proto.InternalMessageInfo
func (m *StatValue) GetFloatNumeratorVal() float64 {
if m != nil {
return m.FloatNumeratorVal
}
return 0
}
func (m *StatValue) GetFloatDenominatorVal() float64 {
if m != nil {
return m.FloatDenominatorVal
}
return 0
}
func (m *StatValue) GetIntNumeratorVal() int64 {
if m != nil {
return m.IntNumeratorVal
}
return 0
}
func (m *StatValue) GetIntDenominatorVal() int64 {
if m != nil {
return m.IntDenominatorVal
}
return 0
}
func (m *StatValue) GetStringVal() string {
if m != nil {
return m.StringVal
}
return ""
}
func (m *StatValue) GetBoolVal() bool {
if m != nil {
return m.BoolVal
}
return false
}
func (m *StatValue) GetUnit() string {
if m != nil {
return m.Unit
}
return ""
}
func (m *StatValue) GetDesc() string {
if m != nil {
return m.Desc
}
return ""
}
func init() {
proto.RegisterType((*FingerprintRequest)(nil), "hashicorp.nomad.plugins.device.FingerprintRequest")
proto.RegisterType((*FingerprintResponse)(nil), "hashicorp.nomad.plugins.device.FingerprintResponse")
@ -560,6 +913,15 @@ func init() {
proto.RegisterMapType((map[string]string)(nil), "hashicorp.nomad.plugins.device.ContainerReservation.EnvsEntry")
proto.RegisterType((*Mount)(nil), "hashicorp.nomad.plugins.device.Mount")
proto.RegisterType((*DeviceSpec)(nil), "hashicorp.nomad.plugins.device.DeviceSpec")
proto.RegisterType((*StatsRequest)(nil), "hashicorp.nomad.plugins.device.StatsRequest")
proto.RegisterType((*StatsResponse)(nil), "hashicorp.nomad.plugins.device.StatsResponse")
proto.RegisterType((*DeviceGroupStats)(nil), "hashicorp.nomad.plugins.device.DeviceGroupStats")
proto.RegisterMapType((map[string]*DeviceStats)(nil), "hashicorp.nomad.plugins.device.DeviceGroupStats.InstanceStatsEntry")
proto.RegisterType((*DeviceStats)(nil), "hashicorp.nomad.plugins.device.DeviceStats")
proto.RegisterType((*StatObject)(nil), "hashicorp.nomad.plugins.device.StatObject")
proto.RegisterMapType((map[string]*StatValue)(nil), "hashicorp.nomad.plugins.device.StatObject.AttributesEntry")
proto.RegisterMapType((map[string]*StatObject)(nil), "hashicorp.nomad.plugins.device.StatObject.NestedEntry")
proto.RegisterType((*StatValue)(nil), "hashicorp.nomad.plugins.device.StatValue")
}
// Reference imports to suppress errors if they are not otherwise used.
@ -583,6 +945,8 @@ type DevicePluginClient interface {
// this to run any setup steps and provides the mounting details to
// the Nomad client
Reserve(ctx context.Context, in *ReserveRequest, opts ...grpc.CallOption) (*ReserveResponse, error)
// Stats returns a stream of device statistics.
Stats(ctx context.Context, in *StatsRequest, opts ...grpc.CallOption) (DevicePlugin_StatsClient, error)
}
type devicePluginClient struct {
@ -634,6 +998,38 @@ func (c *devicePluginClient) Reserve(ctx context.Context, in *ReserveRequest, op
return out, nil
}
func (c *devicePluginClient) Stats(ctx context.Context, in *StatsRequest, opts ...grpc.CallOption) (DevicePlugin_StatsClient, error) {
stream, err := c.cc.NewStream(ctx, &_DevicePlugin_serviceDesc.Streams[1], "/hashicorp.nomad.plugins.device.DevicePlugin/Stats", opts...)
if err != nil {
return nil, err
}
x := &devicePluginStatsClient{stream}
if err := x.ClientStream.SendMsg(in); err != nil {
return nil, err
}
if err := x.ClientStream.CloseSend(); err != nil {
return nil, err
}
return x, nil
}
type DevicePlugin_StatsClient interface {
Recv() (*StatsResponse, error)
grpc.ClientStream
}
type devicePluginStatsClient struct {
grpc.ClientStream
}
func (x *devicePluginStatsClient) Recv() (*StatsResponse, error) {
m := new(StatsResponse)
if err := x.ClientStream.RecvMsg(m); err != nil {
return nil, err
}
return m, nil
}
// DevicePluginServer is the server API for DevicePlugin service.
type DevicePluginServer interface {
// Fingerprint allows the device plugin to return a set of
@ -645,6 +1041,8 @@ type DevicePluginServer interface {
// this to run any setup steps and provides the mounting details to
// the Nomad client
Reserve(context.Context, *ReserveRequest) (*ReserveResponse, error)
// Stats returns a stream of device statistics.
Stats(*StatsRequest, DevicePlugin_StatsServer) error
}
func RegisterDevicePluginServer(s *grpc.Server, srv DevicePluginServer) {
@ -690,6 +1088,27 @@ func _DevicePlugin_Reserve_Handler(srv interface{}, ctx context.Context, dec fun
return interceptor(ctx, in, info, handler)
}
func _DevicePlugin_Stats_Handler(srv interface{}, stream grpc.ServerStream) error {
m := new(StatsRequest)
if err := stream.RecvMsg(m); err != nil {
return err
}
return srv.(DevicePluginServer).Stats(m, &devicePluginStatsServer{stream})
}
type DevicePlugin_StatsServer interface {
Send(*StatsResponse) error
grpc.ServerStream
}
type devicePluginStatsServer struct {
grpc.ServerStream
}
func (x *devicePluginStatsServer) Send(m *StatsResponse) error {
return x.ServerStream.SendMsg(m)
}
var _DevicePlugin_serviceDesc = grpc.ServiceDesc{
ServiceName: "hashicorp.nomad.plugins.device.DevicePlugin",
HandlerType: (*DevicePluginServer)(nil),
@ -705,57 +1124,87 @@ var _DevicePlugin_serviceDesc = grpc.ServiceDesc{
Handler: _DevicePlugin_Fingerprint_Handler,
ServerStreams: true,
},
{
StreamName: "Stats",
Handler: _DevicePlugin_Stats_Handler,
ServerStreams: true,
},
},
Metadata: "github.com/hashicorp/nomad/plugins/device/proto/device.proto",
}
func init() {
proto.RegisterFile("github.com/hashicorp/nomad/plugins/device/proto/device.proto", fileDescriptor_device_7496b084f8b5ea81)
proto.RegisterFile("github.com/hashicorp/nomad/plugins/device/proto/device.proto", fileDescriptor_device_ebefe60214c28313)
}
var fileDescriptor_device_7496b084f8b5ea81 = []byte{
// 682 bytes of a gzipped FileDescriptorProto
0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0xff, 0xa4, 0x55, 0xdd, 0x6e, 0xd3, 0x4a,
0x10, 0x3e, 0x4e, 0x9a, 0x26, 0x99, 0xf4, 0xb4, 0xe7, 0xec, 0xa9, 0x8e, 0xac, 0xf0, 0x17, 0x59,
0x42, 0xaa, 0x40, 0xd8, 0x28, 0x45, 0x02, 0x01, 0x45, 0xa2, 0xa4, 0x40, 0x24, 0x68, 0x2b, 0xc3,
0x0d, 0x20, 0x61, 0x39, 0xf6, 0x2a, 0x5e, 0xd5, 0xd9, 0x35, 0xbb, 0xeb, 0x54, 0xe6, 0x89, 0x78,
0x06, 0xde, 0x81, 0xf7, 0xe1, 0x12, 0x79, 0x77, 0x9d, 0xb8, 0x80, 0x48, 0x0b, 0x57, 0xbb, 0x33,
0xdf, 0x7c, 0x33, 0x93, 0xd9, 0x6f, 0x1c, 0x78, 0x38, 0x25, 0x32, 0xc9, 0x27, 0x6e, 0xc4, 0x66,
0x5e, 0x12, 0x8a, 0x84, 0x44, 0x8c, 0x67, 0x1e, 0x65, 0xb3, 0x30, 0xf6, 0xb2, 0x34, 0x9f, 0x12,
0x2a, 0xbc, 0x18, 0xcf, 0x49, 0x84, 0xbd, 0x8c, 0x33, 0xc9, 0x8c, 0xe1, 0x2a, 0x03, 0x5d, 0x5d,
0x50, 0x5c, 0x45, 0x71, 0x0d, 0xc5, 0xd5, 0x51, 0xce, 0x36, 0xa0, 0xa7, 0x84, 0x4e, 0x31, 0xcf,
0x38, 0xa1, 0xd2, 0xc7, 0x1f, 0x72, 0x2c, 0xa4, 0x83, 0xe1, 0xbf, 0x33, 0x5e, 0x91, 0x31, 0x2a,
0x30, 0x3a, 0x84, 0x0d, 0x4d, 0x0b, 0xa6, 0x9c, 0xe5, 0x99, 0x6d, 0x0d, 0x9a, 0x3b, 0xbd, 0xe1,
0x4d, 0xf7, 0xd7, 0x35, 0xdc, 0x91, 0x3a, 0x9e, 0x95, 0x14, 0xbf, 0x17, 0x2f, 0x0d, 0xe7, 0x4b,
0x03, 0x7a, 0x35, 0x10, 0xfd, 0x0f, 0xeb, 0x73, 0x4c, 0x63, 0xc6, 0x6d, 0x6b, 0x60, 0xed, 0x74,
0x7d, 0x63, 0xa1, 0x6b, 0x60, 0x68, 0x81, 0x2c, 0x32, 0x6c, 0x37, 0x14, 0x08, 0xda, 0xf5, 0xba,
0xc8, 0x70, 0x2d, 0x80, 0x86, 0x33, 0x6c, 0x37, 0xeb, 0x01, 0x87, 0xe1, 0x0c, 0xa3, 0xe7, 0xd0,
0xd6, 0x96, 0xb0, 0xd7, 0x54, 0xd3, 0xee, 0xea, 0xa6, 0x25, 0x8e, 0x24, 0x8e, 0x75, 0x7f, 0x7e,
0x45, 0x47, 0xef, 0x00, 0x42, 0x29, 0x39, 0x99, 0xe4, 0x12, 0x0b, 0xbb, 0xa5, 0x92, 0x3d, 0xb8,
0xc0, 0x04, 0xdc, 0xc7, 0x0b, 0xf6, 0x01, 0x95, 0xbc, 0xf0, 0x6b, 0xe9, 0xfa, 0x7b, 0xb0, 0xf5,
0x1d, 0x8c, 0xfe, 0x81, 0xe6, 0x09, 0x2e, 0xcc, 0x40, 0xca, 0x2b, 0xda, 0x86, 0xd6, 0x3c, 0x4c,
0xf3, 0x6a, 0x0e, 0xda, 0xb8, 0xdf, 0xb8, 0x67, 0x39, 0x9f, 0x2d, 0xd8, 0x3c, 0xdb, 0x37, 0xda,
0x84, 0xc6, 0x78, 0x64, 0xd8, 0x8d, 0xf1, 0x08, 0xd9, 0xd0, 0x4e, 0x70, 0x98, 0xca, 0xa4, 0x50,
0xf4, 0x8e, 0x5f, 0x99, 0xe8, 0x16, 0x20, 0x7d, 0x0d, 0x62, 0x2c, 0x22, 0x4e, 0x32, 0x49, 0x18,
0x35, 0xa3, 0xfc, 0x57, 0x23, 0xa3, 0x25, 0x80, 0x8e, 0xa0, 0x97, 0x9c, 0x06, 0x29, 0x8b, 0xc2,
0x94, 0xc8, 0xc2, 0x5e, 0x1b, 0x58, 0xe7, 0x9b, 0x6a, 0x79, 0xbc, 0x30, 0x2c, 0x1f, 0x92, 0xd3,
0xea, 0xee, 0xb8, 0x65, 0xef, 0x75, 0x14, 0x5d, 0x06, 0xc8, 0x22, 0x12, 0x4c, 0x72, 0x11, 0x90,
0xd8, 0xfc, 0x86, 0x4e, 0x16, 0x91, 0xfd, 0x5c, 0x8c, 0x63, 0xc7, 0x83, 0x4d, 0x1f, 0x0b, 0xcc,
0xe7, 0xd8, 0xa8, 0x16, 0x5d, 0x01, 0xf3, 0xe4, 0x01, 0x89, 0x85, 0x12, 0x67, 0xd7, 0xef, 0x6a,
0xcf, 0x38, 0x16, 0x4e, 0x0a, 0x5b, 0x0b, 0x82, 0x11, 0xf4, 0x1b, 0xf8, 0x3b, 0x62, 0x54, 0x86,
0x84, 0x62, 0x1e, 0x70, 0x2c, 0x54, 0x91, 0xde, 0xf0, 0xce, 0xaa, 0x9f, 0xf1, 0xa4, 0x22, 0xe9,
0x84, 0x61, 0x39, 0x11, 0x7f, 0x23, 0xaa, 0x79, 0x9d, 0x4f, 0x0d, 0xd8, 0xfe, 0x59, 0x18, 0xf2,
0x61, 0x0d, 0xd3, 0xb9, 0x30, 0xcb, 0xf3, 0xe8, 0x77, 0x4a, 0xb9, 0x07, 0x74, 0x6e, 0xd4, 0xa3,
0x72, 0xa1, 0x3d, 0x58, 0x9f, 0xb1, 0x9c, 0x4a, 0x61, 0x37, 0x54, 0xd6, 0xeb, 0xab, 0xb2, 0xbe,
0x2c, 0xa3, 0x7d, 0x43, 0x42, 0xa3, 0xe5, 0x76, 0x34, 0x15, 0xff, 0xc6, 0xf9, 0xde, 0xf1, 0x55,
0x86, 0xa3, 0xc5, 0x66, 0xf4, 0xef, 0x42, 0x77, 0xd1, 0xd7, 0x85, 0x64, 0xfb, 0x1e, 0x5a, 0xaa,
0x1f, 0x74, 0x09, 0xba, 0x32, 0x14, 0x27, 0x41, 0x16, 0xca, 0xa4, 0x7a, 0xef, 0xd2, 0x71, 0x1c,
0xca, 0xa4, 0x04, 0x13, 0x26, 0xa4, 0x06, 0x75, 0x8e, 0x4e, 0xe9, 0xa8, 0x40, 0x8e, 0xc3, 0x38,
0x60, 0x34, 0x2d, 0x94, 0x66, 0x3b, 0x7e, 0xa7, 0x74, 0x1c, 0xd1, 0xb4, 0x70, 0x12, 0x80, 0x65,
0xbf, 0x7f, 0x50, 0x64, 0x00, 0xbd, 0x0c, 0xf3, 0x19, 0x11, 0x82, 0x30, 0x2a, 0xcc, 0x6a, 0xd4,
0x5d, 0xc3, 0xaf, 0x16, 0x6c, 0xe8, 0x52, 0xc7, 0x6a, 0x5e, 0xe8, 0x23, 0xf4, 0x6a, 0x1f, 0x52,
0x34, 0x5c, 0x35, 0xd7, 0x1f, 0xbf, 0xc5, 0xfd, 0xdd, 0x0b, 0x71, 0xb4, 0xb0, 0x9d, 0xbf, 0x6e,
0x5b, 0x28, 0x85, 0xb6, 0xd1, 0x3b, 0x5a, 0xb9, 0x97, 0x67, 0x37, 0xa9, 0xef, 0x9d, 0x3b, 0xbe,
0xaa, 0xb7, 0xdf, 0x7e, 0xdb, 0x52, 0xff, 0x38, 0x93, 0x75, 0x75, 0xec, 0x7e, 0x0b, 0x00, 0x00,
0xff, 0xff, 0x5f, 0xeb, 0xc2, 0x59, 0xb8, 0x06, 0x00, 0x00,
var fileDescriptor_device_ebefe60214c28313 = []byte{
// 1086 bytes of a gzipped FileDescriptorProto
0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0xff, 0xa4, 0x56, 0xeb, 0x8e, 0xdb, 0x44,
0x14, 0x26, 0xf7, 0xe4, 0x64, 0x2f, 0xdd, 0xd9, 0x05, 0x85, 0x40, 0xe9, 0xca, 0x12, 0x52, 0x59,
0xa8, 0x53, 0xa5, 0x08, 0xaa, 0x42, 0xa1, 0xed, 0xa6, 0xd0, 0x95, 0x60, 0xb7, 0x72, 0xab, 0x4a,
0x2d, 0x12, 0xd6, 0xc4, 0x9e, 0xc6, 0xd3, 0xda, 0x33, 0xc6, 0x33, 0x4e, 0x15, 0xde, 0x80, 0x37,
0xe1, 0x4f, 0x5f, 0x80, 0x77, 0x80, 0x87, 0xe0, 0x49, 0xd0, 0x5c, 0x9c, 0x38, 0xdb, 0xa5, 0x49,
0xe0, 0x97, 0xe7, 0x5c, 0xbe, 0x6f, 0x8e, 0xcf, 0x39, 0x73, 0x66, 0xe0, 0xeb, 0x09, 0x95, 0x51,
0x3e, 0x76, 0x03, 0x9e, 0x0c, 0x22, 0x2c, 0x22, 0x1a, 0xf0, 0x2c, 0x1d, 0x30, 0x9e, 0xe0, 0x70,
0x90, 0xc6, 0xf9, 0x84, 0x32, 0x31, 0x08, 0xc9, 0x94, 0x06, 0x64, 0x90, 0x66, 0x5c, 0x72, 0x2b,
0xb8, 0x5a, 0x40, 0x1f, 0xcd, 0x21, 0xae, 0x86, 0xb8, 0x16, 0xe2, 0x1a, 0xaf, 0xfe, 0x95, 0x09,
0xe7, 0x93, 0xd8, 0x42, 0xc7, 0xf9, 0xf3, 0x81, 0xa4, 0x09, 0x11, 0x12, 0x27, 0xa9, 0x21, 0x70,
0x0e, 0x00, 0x7d, 0x47, 0xd9, 0x84, 0x64, 0x69, 0x46, 0x99, 0xf4, 0xc8, 0x2f, 0x39, 0x11, 0xd2,
0x21, 0xb0, 0xbf, 0xa4, 0x15, 0x29, 0x67, 0x82, 0xa0, 0x53, 0xd8, 0x32, 0xbc, 0xfe, 0x24, 0xe3,
0x79, 0xda, 0xab, 0x1c, 0xd6, 0xae, 0x76, 0x87, 0x9f, 0xba, 0x6f, 0x0f, 0xc2, 0x1d, 0xe9, 0xcf,
0xf7, 0x0a, 0xe2, 0x75, 0xc3, 0x85, 0xe0, 0xfc, 0x59, 0x85, 0x6e, 0xc9, 0x88, 0xde, 0x83, 0xe6,
0x94, 0xb0, 0x90, 0x67, 0xbd, 0xca, 0x61, 0xe5, 0x6a, 0xc7, 0xb3, 0x12, 0xba, 0x02, 0x16, 0xe6,
0xcb, 0x59, 0x4a, 0x7a, 0x55, 0x6d, 0x04, 0xa3, 0x7a, 0x3c, 0x4b, 0x49, 0xc9, 0x81, 0xe1, 0x84,
0xf4, 0x6a, 0x65, 0x87, 0x53, 0x9c, 0x10, 0xf4, 0x00, 0x5a, 0x46, 0x12, 0xbd, 0xba, 0x0e, 0xda,
0x5d, 0x1d, 0xb4, 0x24, 0x81, 0x24, 0xa1, 0x89, 0xcf, 0x2b, 0xe0, 0xe8, 0x27, 0x00, 0x2c, 0x65,
0x46, 0xc7, 0xb9, 0x24, 0xa2, 0xd7, 0xd0, 0x64, 0x5f, 0x6d, 0x90, 0x01, 0xf7, 0xee, 0x1c, 0x7d,
0x9f, 0xc9, 0x6c, 0xe6, 0x95, 0xe8, 0xfa, 0xb7, 0x61, 0xf7, 0x9c, 0x19, 0x5d, 0x82, 0xda, 0x4b,
0x32, 0xb3, 0x09, 0x51, 0x4b, 0x74, 0x00, 0x8d, 0x29, 0x8e, 0xf3, 0x22, 0x0f, 0x46, 0xb8, 0x55,
0xbd, 0x59, 0x71, 0xfe, 0xa8, 0xc0, 0xce, 0x72, 0xdc, 0x68, 0x07, 0xaa, 0x27, 0x23, 0x8b, 0xae,
0x9e, 0x8c, 0x50, 0x0f, 0x5a, 0x11, 0xc1, 0xb1, 0x8c, 0x66, 0x1a, 0xde, 0xf6, 0x0a, 0x11, 0x5d,
0x03, 0x64, 0x96, 0x7e, 0x48, 0x44, 0x90, 0xd1, 0x54, 0x52, 0xce, 0x6c, 0x2a, 0xf7, 0x8c, 0x65,
0xb4, 0x30, 0xa0, 0x33, 0xe8, 0x46, 0xaf, 0xfc, 0x98, 0x07, 0x38, 0xa6, 0x72, 0xd6, 0xab, 0x1f,
0x56, 0xd6, 0xcb, 0xaa, 0xfa, 0xfc, 0x60, 0x51, 0x1e, 0x44, 0xaf, 0x8a, 0xb5, 0xe3, 0xaa, 0xd8,
0xcb, 0x56, 0xf4, 0x21, 0x40, 0x1a, 0x50, 0x7f, 0x9c, 0x0b, 0x9f, 0x86, 0xf6, 0x1f, 0xda, 0x69,
0x40, 0xef, 0xe5, 0xe2, 0x24, 0x74, 0x06, 0xb0, 0xe3, 0x11, 0x41, 0xb2, 0x29, 0xb1, 0x5d, 0x8b,
0x2e, 0x83, 0x2d, 0xb9, 0x4f, 0x43, 0xa1, 0x9b, 0xb3, 0xe3, 0x75, 0x8c, 0xe6, 0x24, 0x14, 0x4e,
0x0c, 0xbb, 0x73, 0x80, 0x6d, 0xe8, 0xa7, 0xb0, 0x1d, 0x70, 0x26, 0x31, 0x65, 0x24, 0xf3, 0x33,
0x22, 0xf4, 0x26, 0xdd, 0xe1, 0xe7, 0xab, 0x7e, 0xe3, 0xb8, 0x00, 0x19, 0x42, 0xac, 0x32, 0xe2,
0x6d, 0x05, 0x25, 0xad, 0xf3, 0x7b, 0x15, 0x0e, 0x2e, 0x72, 0x43, 0x1e, 0xd4, 0x09, 0x9b, 0x0a,
0x7b, 0x78, 0xbe, 0xf9, 0x2f, 0x5b, 0xb9, 0xf7, 0xd9, 0xd4, 0x76, 0x8f, 0xe6, 0x42, 0xb7, 0xa1,
0x99, 0xf0, 0x9c, 0x49, 0xd1, 0xab, 0x6a, 0xd6, 0x8f, 0x57, 0xb1, 0xfe, 0xa8, 0xbc, 0x3d, 0x0b,
0x42, 0xa3, 0xc5, 0xe9, 0xa8, 0x69, 0xfc, 0xd1, 0x7a, 0x75, 0x7c, 0x94, 0x92, 0x60, 0x7e, 0x32,
0xfa, 0x5f, 0x42, 0x67, 0x1e, 0xd7, 0x46, 0x6d, 0xfb, 0x33, 0x34, 0x74, 0x3c, 0xe8, 0x03, 0xe8,
0x48, 0x2c, 0x5e, 0xfa, 0x29, 0x96, 0x51, 0x51, 0x6f, 0xa5, 0x78, 0x88, 0x65, 0xa4, 0x8c, 0x11,
0x17, 0xd2, 0x18, 0x0d, 0x47, 0x5b, 0x29, 0x0a, 0x63, 0x46, 0x70, 0xe8, 0x73, 0x16, 0xcf, 0x74,
0xcf, 0xb6, 0xbd, 0xb6, 0x52, 0x9c, 0xb1, 0x78, 0xe6, 0x44, 0x00, 0x8b, 0x78, 0xff, 0xc7, 0x26,
0x87, 0xd0, 0x4d, 0x49, 0x96, 0x50, 0x21, 0x28, 0x67, 0xc2, 0x1e, 0x8d, 0xb2, 0xca, 0xd9, 0x81,
0xad, 0x47, 0x12, 0x4b, 0x51, 0xcc, 0xd1, 0xa7, 0xb0, 0x6d, 0x65, 0xdb, 0x70, 0x0f, 0xa0, 0xa9,
0x47, 0x67, 0x51, 0xfe, 0xeb, 0x1b, 0x4c, 0x0e, 0xc3, 0x64, 0xf1, 0xce, 0xeb, 0x2a, 0x5c, 0x3a,
0x6f, 0xfc, 0xd7, 0x01, 0x8a, 0xa0, 0x5e, 0x9a, 0x9c, 0x7a, 0xad, 0x74, 0xa5, 0x61, 0xa9, 0xd7,
0xe8, 0x05, 0xec, 0x50, 0x26, 0x24, 0x66, 0x01, 0xf1, 0x85, 0x62, 0xb4, 0xd3, 0xf2, 0x78, 0xd3,
0x30, 0xdd, 0x13, 0x4b, 0xa3, 0x25, 0xd3, 0xaa, 0xdb, 0xb4, 0xac, 0xeb, 0x27, 0x80, 0xde, 0x74,
0xba, 0xa0, 0x6f, 0xee, 0x96, 0xfb, 0x66, 0xed, 0xdb, 0xc6, 0x24, 0xab, 0xd4, 0x64, 0x7f, 0x55,
0x8a, 0xbb, 0xc6, 0xa4, 0xea, 0x18, 0x5a, 0x22, 0x4f, 0x12, 0x9c, 0xcd, 0xec, 0xa1, 0xff, 0x64,
0x15, 0xb1, 0xc2, 0x3d, 0x51, 0x7c, 0x5e, 0x81, 0x44, 0x77, 0xa0, 0x61, 0xd2, 0x64, 0x62, 0x3b,
0x5a, 0x87, 0xe2, 0x6c, 0xfc, 0x82, 0x04, 0xd2, 0x33, 0x40, 0x74, 0x13, 0x3a, 0xf3, 0x2b, 0x59,
0x97, 0xa2, 0x3b, 0xec, 0xbb, 0xe6, 0xd2, 0x76, 0x8b, 0x4b, 0xdb, 0x7d, 0x5c, 0x78, 0x78, 0x0b,
0x67, 0xe7, 0xb7, 0x1a, 0xc0, 0x82, 0x0f, 0x9d, 0x42, 0x93, 0x11, 0x21, 0x49, 0x68, 0x3b, 0xeb,
0x8b, 0xf5, 0x63, 0x71, 0x4f, 0x35, 0xd0, 0x54, 0xc9, 0xb2, 0xa0, 0x67, 0x4b, 0xf7, 0x9c, 0x19,
0x2b, 0xb7, 0x36, 0xe0, 0x7c, 0xdb, 0x35, 0x47, 0xa0, 0x5b, 0xda, 0xf2, 0x82, 0x9a, 0xdf, 0x59,
0xae, 0xf9, 0x46, 0x79, 0x9d, 0x97, 0xbc, 0x1f, 0xad, 0x73, 0x9b, 0x7e, 0xbb, 0xbc, 0xd5, 0x06,
0x5d, 0x50, 0x6a, 0xae, 0xd7, 0x55, 0xe8, 0xcc, 0x0d, 0xc8, 0x85, 0xfd, 0xe7, 0x31, 0xc7, 0xd2,
0x67, 0x79, 0x42, 0x32, 0x2c, 0x79, 0xe6, 0x4f, 0x71, 0xac, 0x37, 0xad, 0x78, 0x7b, 0xda, 0x74,
0x5a, 0x58, 0x9e, 0xe0, 0x18, 0x0d, 0xe1, 0x5d, 0xe3, 0x1f, 0x12, 0xc6, 0x13, 0xca, 0xe6, 0x88,
0xaa, 0x46, 0x18, 0xb2, 0xd1, 0xc2, 0xa6, 0x30, 0x47, 0xb0, 0x47, 0xd9, 0xf9, 0x1d, 0x54, 0xff,
0xd4, 0xbc, 0x5d, 0xca, 0x96, 0xf9, 0x5d, 0xd8, 0x57, 0xbe, 0xe7, 0xd9, 0xeb, 0xda, 0x5b, 0xd1,
0x9c, 0xe3, 0xbe, 0x0c, 0x20, 0x64, 0x46, 0xd9, 0x44, 0xbb, 0x35, 0x74, 0xae, 0x3a, 0x46, 0xa3,
0xcc, 0xef, 0x43, 0x7b, 0xcc, 0x79, 0xac, 0x8d, 0x4d, 0xf3, 0x86, 0x50, 0xb2, 0x32, 0x21, 0xa8,
0xe7, 0x8c, 0xca, 0x5e, 0xcb, 0xcc, 0x14, 0xb5, 0x56, 0x3a, 0xf5, 0xa0, 0xe8, 0xb5, 0x8d, 0x4e,
0xad, 0x87, 0x7f, 0x57, 0x61, 0xcb, 0x1c, 0xc6, 0x87, 0x3a, 0xbb, 0xe8, 0x57, 0xe8, 0x96, 0x1e,
0x9c, 0x68, 0xb8, 0xaa, 0x0a, 0x6f, 0xbe, 0x59, 0xfb, 0x37, 0x36, 0xc2, 0x98, 0x79, 0xec, 0xbc,
0x73, 0xbd, 0x82, 0x62, 0x68, 0xd9, 0x77, 0x01, 0x5a, 0xf9, 0x7e, 0x59, 0x7e, 0x71, 0xf4, 0x07,
0x6b, 0xfb, 0x17, 0xfb, 0xa1, 0x08, 0x1a, 0x66, 0x00, 0x7d, 0xb6, 0x4e, 0xa7, 0x15, 0x37, 0x49,
0xff, 0xda, 0x9a, 0xde, 0x8b, 0xff, 0xba, 0xd7, 0x7a, 0xd6, 0x30, 0x13, 0xa4, 0xa9, 0x3f, 0x37,
0xfe, 0x09, 0x00, 0x00, 0xff, 0xff, 0xe4, 0x5c, 0x8f, 0xd7, 0x6b, 0x0c, 0x00, 0x00,
}

View file

@ -2,6 +2,8 @@ syntax = "proto3";
package hashicorp.nomad.plugins.device;
option go_package = "proto";
import "google/protobuf/timestamp.proto";
// DevicePlugin is the API exposed by device plugins
service DevicePlugin {
// Fingerprint allows the device plugin to return a set of
@ -14,6 +16,9 @@ service DevicePlugin {
// this to run any setup steps and provides the mounting details to
// the Nomad client
rpc Reserve(ReserveRequest) returns (ReserveResponse) {}
// Stats returns a stream of device statistics.
rpc Stats(StatsRequest) returns (stream StatsResponse) {}
}
// FingerprintRequest is used to request for devices to be fingerprinted.
@ -129,3 +134,75 @@ message DeviceSpec {
string permissions = 3;
}
// StatsRequest is used to parameterize the retrieval of statistics.
message StatsRequest {}
// StatsResponse returns the statistics for each device group.
message StatsResponse {
// groups contains statistics for each device group.
repeated DeviceGroupStats groups = 1;
}
// DeviceGroupStats contains statistics for each device of a particular
// device group, identified by the vendor, type and name of the device.
message DeviceGroupStats {
string vendor = 1;
string type = 2;
string name = 3;
// instance_stats is a mapping of each device ID to its statistics.
map<string, DeviceStats> instance_stats = 4;
}
// DeviceStats is the statistics for an individual device
message DeviceStats {
// summary exposes a single summary metric that should be the most
// informative to users.
StatValue summary = 1;
// stats contains the verbose statistics for the device.
StatObject stats = 2;
// timestamp is the time the statistics were collected.
google.protobuf.Timestamp timestamp = 3;
}
// StatObject is a collection of statistics either exposed at the top
// level or via nested StatObjects.
message StatObject {
// nested is a mapping of object name to a nested stats object.
map<string, StatObject> nested = 1;
// attributes is a mapping of statistic name to its value.
map<string, StatValue> attributes = 2;
}
// StatValue exposes the values of a particular statistic. The value may
// be of type double, integer, string or boolean. Numeric types can be
// exposed as a single value or as a fraction.
message StatValue {
// float_numerator_val exposes a floating point value. If denominator
// is set it is assumed to be a fractional value, otherwise it is a
// scalar.
double float_numerator_val = 1;
double float_denominator_val = 2;
// int_numerator_val exposes a int value. If denominator
// is set it is assumed to be a fractional value, otherwise it is a
// scalar.
int64 int_numerator_val = 3;
int64 int_denominator_val = 4;
// string_val exposes a string value. These are likely annotations.
string string_val = 5;
// bool_val exposes a boolean statistic.
bool bool_val = 6;
// unit gives the unit type: °F, %, MHz, MB, etc.
string unit = 7;
// desc provides a human readable description of the statistic.
string desc = 8;
}

View file

@ -64,3 +64,41 @@ func (d *devicePluginServer) Reserve(ctx context.Context, req *proto.ReserveRequ
return presp, nil
}
func (d *devicePluginServer) Stats(req *proto.StatsRequest, stream proto.DevicePlugin_StatsServer) error {
ctx := stream.Context()
outCh, err := d.impl.Stats(ctx)
if err != nil {
return err
}
for {
select {
case <-ctx.Done():
return nil
case resp, ok := <-outCh:
// The output channel has been closed, end the stream
if !ok {
return nil
}
// Handle any error
if resp.Error != nil {
return resp.Error
}
// Convert the devices
out := convertStructDeviceGroupsStats(resp.Groups)
// Build the response
presp := &proto.StatsResponse{
Groups: out,
}
// Send the devices
if err := stream.Send(presp); err != nil {
return err
}
}
}
}

View file

@ -1,6 +1,7 @@
package device
import "github.com/hashicorp/nomad/plugins/device/proto"
import "github.com/golang/protobuf/ptypes"
// convertProtoDeviceGroups converts between a list of proto and structs DeviceGroup
func convertProtoDeviceGroups(in []*proto.DeviceGroup) []*DeviceGroup {
@ -23,11 +24,11 @@ func convertProtoDeviceGroup(in *proto.DeviceGroup) *DeviceGroup {
}
return &DeviceGroup{
Vendor: in.GetVendor(),
Type: in.GetDeviceType(),
Name: in.GetDeviceName(),
Devices: convertProtoDevices(in.GetDevices()),
Attributes: in.GetAttributes(),
Vendor: in.Vendor,
Type: in.DeviceType,
Name: in.DeviceName,
Devices: convertProtoDevices(in.Devices),
Attributes: in.Attributes,
}
}
@ -52,10 +53,10 @@ func convertProtoDevice(in *proto.DetectedDevice) *Device {
}
return &Device{
ID: in.GetID(),
Healthy: in.GetHealthy(),
HealthDesc: in.GetHealthDescription(),
HwLocality: convertProtoDeviceLocality(in.GetHwLocality()),
ID: in.ID,
Healthy: in.Healthy,
HealthDesc: in.HealthDescription,
HwLocality: convertProtoDeviceLocality(in.HwLocality),
}
}
@ -66,7 +67,7 @@ func convertProtoDeviceLocality(in *proto.DeviceLocality) *DeviceLocality {
}
return &DeviceLocality{
PciBusID: in.GetPciBusId(),
PciBusID: in.PciBusId,
}
}
@ -78,9 +79,9 @@ func convertProtoContainerReservation(in *proto.ContainerReservation) *Container
}
return &ContainerReservation{
Envs: in.GetEnvs(),
Mounts: convertProtoMounts(in.GetMounts()),
Devices: convertProtoDeviceSpecs(in.GetDevices()),
Envs: in.Envs,
Mounts: convertProtoMounts(in.Mounts),
Devices: convertProtoDeviceSpecs(in.Devices),
}
}
@ -105,9 +106,9 @@ func convertProtoMount(in *proto.Mount) *Mount {
}
return &Mount{
TaskPath: in.GetTaskPath(),
HostPath: in.GetHostPath(),
ReadOnly: in.GetReadOnly(),
TaskPath: in.TaskPath,
HostPath: in.HostPath,
ReadOnly: in.ReadOnly,
}
}
@ -132,9 +133,9 @@ func convertProtoDeviceSpec(in *proto.DeviceSpec) *DeviceSpec {
}
return &DeviceSpec{
TaskPath: in.GetTaskPath(),
HostPath: in.GetHostPath(),
CgroupPerms: in.GetPermissions(),
TaskPath: in.TaskPath,
HostPath: in.HostPath,
CgroupPerms: in.Permissions,
}
}
@ -273,3 +274,191 @@ func convertStructDeviceSpec(in *DeviceSpec) *proto.DeviceSpec {
Permissions: in.CgroupPerms,
}
}
// convertProtoDeviceGroupsStats converts between a list of struct and proto
// DeviceGroupStats
func convertProtoDeviceGroupsStats(in []*proto.DeviceGroupStats) []*DeviceGroupStats {
if in == nil {
return nil
}
out := make([]*DeviceGroupStats, len(in))
for i, m := range in {
out[i] = convertProtoDeviceGroupStats(m)
}
return out
}
// convertProtoDeviceGroupStats converts between a proto and struct
// DeviceGroupStats
func convertProtoDeviceGroupStats(in *proto.DeviceGroupStats) *DeviceGroupStats {
if in == nil {
return nil
}
out := &DeviceGroupStats{
Vendor: in.Vendor,
Type: in.Type,
Name: in.Name,
InstanceStats: make(map[string]*DeviceStats, len(in.InstanceStats)),
}
for k, v := range in.InstanceStats {
out.InstanceStats[k] = convertProtoDeviceStats(v)
}
return out
}
// convertProtoDeviceStats converts between a proto and struct DeviceStats
func convertProtoDeviceStats(in *proto.DeviceStats) *DeviceStats {
if in == nil {
return nil
}
ts, err := ptypes.Timestamp(in.Timestamp)
if err != nil {
return nil
}
return &DeviceStats{
Summary: convertProtoStatValue(in.Summary),
Stats: convertProtoStatObject(in.Stats),
Timestamp: ts,
}
}
// convertProtoStatObject converts between a proto and struct StatObject
func convertProtoStatObject(in *proto.StatObject) *StatObject {
if in == nil {
return nil
}
out := &StatObject{
Nested: make(map[string]*StatObject, len(in.Nested)),
Attributes: make(map[string]*StatValue, len(in.Attributes)),
}
for k, v := range in.Nested {
out.Nested[k] = convertProtoStatObject(v)
}
for k, v := range in.Attributes {
out.Attributes[k] = convertProtoStatValue(v)
}
return out
}
// convertProtoStatValue converts between a proto and struct StatValue
func convertProtoStatValue(in *proto.StatValue) *StatValue {
if in == nil {
return nil
}
return &StatValue{
FloatNumeratorVal: in.FloatNumeratorVal,
FloatDenominatorVal: in.FloatDenominatorVal,
IntNumeratorVal: in.IntNumeratorVal,
IntDenominatorVal: in.IntDenominatorVal,
StringVal: in.StringVal,
BoolVal: in.BoolVal,
Unit: in.Unit,
Desc: in.Desc,
}
}
// convertStructDeviceGroupsStats converts between a list of struct and proto
// DeviceGroupStats
func convertStructDeviceGroupsStats(in []*DeviceGroupStats) []*proto.DeviceGroupStats {
if in == nil {
return nil
}
out := make([]*proto.DeviceGroupStats, len(in))
for i, m := range in {
out[i] = convertStructDeviceGroupStats(m)
}
return out
}
// convertStructDeviceGroupStats converts between a struct and proto
// DeviceGroupStats
func convertStructDeviceGroupStats(in *DeviceGroupStats) *proto.DeviceGroupStats {
if in == nil {
return nil
}
out := &proto.DeviceGroupStats{
Vendor: in.Vendor,
Type: in.Type,
Name: in.Name,
InstanceStats: make(map[string]*proto.DeviceStats, len(in.InstanceStats)),
}
for k, v := range in.InstanceStats {
out.InstanceStats[k] = convertStructDeviceStats(v)
}
return out
}
// convertStructDeviceStats converts between a struct and proto DeviceStats
func convertStructDeviceStats(in *DeviceStats) *proto.DeviceStats {
if in == nil {
return nil
}
ts, err := ptypes.TimestampProto(in.Timestamp)
if err != nil {
return nil
}
return &proto.DeviceStats{
Summary: convertStructStatValue(in.Summary),
Stats: convertStructStatObject(in.Stats),
Timestamp: ts,
}
}
// convertStructStatObject converts between a struct and proto StatObject
func convertStructStatObject(in *StatObject) *proto.StatObject {
if in == nil {
return nil
}
out := &proto.StatObject{
Nested: make(map[string]*proto.StatObject, len(in.Nested)),
Attributes: make(map[string]*proto.StatValue, len(in.Attributes)),
}
for k, v := range in.Nested {
out.Nested[k] = convertStructStatObject(v)
}
for k, v := range in.Attributes {
out.Attributes[k] = convertStructStatValue(v)
}
return out
}
// convertStructStatValue converts between a struct and proto StatValue
func convertStructStatValue(in *StatValue) *proto.StatValue {
if in == nil {
return nil
}
return &proto.StatValue{
FloatNumeratorVal: in.FloatNumeratorVal,
FloatDenominatorVal: in.FloatDenominatorVal,
IntNumeratorVal: in.IntNumeratorVal,
IntDenominatorVal: in.IntDenominatorVal,
StringVal: in.StringVal,
BoolVal: in.BoolVal,
Unit: in.Unit,
Desc: in.Desc,
}
}

27
plugins/serve.go Normal file
View file

@ -0,0 +1,27 @@
package plugins
import (
"fmt"
log "github.com/hashicorp/go-hclog"
"github.com/hashicorp/nomad/plugins/device"
)
// PluginFactory returns a new plugin instance
type PluginFactory func(log log.Logger) interface{}
// Serve is used to serve a new Nomad plugin
func Serve(f PluginFactory) {
logger := log.New(&log.LoggerOptions{
Level: log.Trace,
JSONFormat: true,
})
plugin := f(logger)
switch p := plugin.(type) {
case device.DevicePlugin:
device.Serve(p, logger)
default:
fmt.Println("Unsupported plugin type")
}
}

View file

@ -0,0 +1,63 @@
This command allows plugin developers to interact with a plugin directly. The
command has subcommands for each plugin type. See the subcommand help text for
detailed usage information.
# Device Example
The `device` subcommand provides a way to interact and visualize the data being
returned by a device plugin. As an example we will run the example device
plugin. To use this command with your own device plugin substitute the example
plugin with your own.
```
# Current working directory should be the root folder: github.com/hashicorp/nomad
# Build the plugin launcher
$ go build github.com/hashicorp/nomad/plugins/shared/cmd/launcher/
# Build the example fs-device plugin
$ go build -o fs-device github.com/hashicorp/nomad/plugins/device/cmd/example/cmd
# Launch the plugin
$ ./launcher device ./fs-device
> Availabile commands are: exit(), fingerprint(), stop_fingerprint(), stats(), stop_stats(), reserve(id1, id2, ...)
> 2018-08-28T14:54:45.658-0700 [INFO ] nomad-plugin-launcher.fs-device: config set: @module=example-fs-device config="example.Config{Dir:".", ListPeriod:"5s", StatsPeriod:"5s", UnhealthyPerm:"-rwxrwxrwx"}" timestamp=2018-08-28T14:54:45.658-0700
^C
2018-08-28T14:54:54.727-0700 [ERROR] nomad-plugin-launcher: error interacting with plugin: error=interrupted
# Lets launch changing the configuration
$ cat <<\EOF >fs-device.config
> config {
> dir = "./plugins"
> stats_period = "2s"
> }
> EOF
$ ./launcher device ./fs-device ./fs-device.config
2018-08-28T14:59:45.886-0700 [INFO ] nomad-plugin-launcher.fs-device: config set: @module=example-fs-device config="example.Config{Dir:"./plugins", ListPeriod:"5s", StatsPeriod:"2s", UnhealthyPerm:"-rwxrwxrwx"}" timestamp=2018-08-28T14:59:45.886-0700
> Availabile commands are: exit(), fingerprint(), stop_fingerprint(), stats(), stop_stats(), reserve(id1, id2, ...)
> fingerprint()
> > fingerprint: &device.FingerprintResponse{
Devices: {
&device.DeviceGroup{
Vendor: "nomad",
Type: "file",
Name: "mock",
Devices: {
&device.Device{
ID: "serve.go",
Healthy: true,
HealthDesc: "",
HwLocality: (*device.DeviceLocality)(nil),
},
},
Attributes: {},
},
},
Error: nil,
}
^C
2018-08-28T15:00:00.329-0700 [ERROR] nomad-plugin-launcher: error interacting with plugin: error=interrupted
```

View file

@ -0,0 +1,364 @@
package command
import (
"context"
"errors"
"fmt"
"io/ioutil"
"os"
"os/exec"
"strings"
hclog "github.com/hashicorp/go-hclog"
plugin "github.com/hashicorp/go-plugin"
"github.com/hashicorp/hcl"
"github.com/hashicorp/hcl/hcl/ast"
hcl2 "github.com/hashicorp/hcl2/hcl"
"github.com/hashicorp/hcl2/hcldec"
"github.com/hashicorp/nomad/plugins/base"
"github.com/hashicorp/nomad/plugins/device"
"github.com/hashicorp/nomad/plugins/shared"
"github.com/hashicorp/nomad/plugins/shared/hclspec"
"github.com/kr/pretty"
"github.com/mitchellh/cli"
"github.com/zclconf/go-cty/cty/msgpack"
)
func DeviceCommandFactory(meta Meta) cli.CommandFactory {
return func() (cli.Command, error) {
return &Device{Meta: meta}, nil
}
}
type Device struct {
Meta
// dev is the plugin device
dev device.DevicePlugin
// spec is the returned and parsed spec.
spec hcldec.Spec
}
func (c *Device) Help() string {
helpText := `
Usage: nomad-plugin-launcher device <device-binary> <config_file>
Device launches the given device binary and provides a REPL for interacting
with it.
General Options:
` + generalOptionsUsage() + `
Device Options:
-trace
Enable trace level log output.
`
return strings.TrimSpace(helpText)
}
func (c *Device) Synopsis() string {
return "REPL for interacting with device plugins"
}
func (c *Device) Run(args []string) int {
var trace bool
cmdFlags := c.FlagSet("device")
cmdFlags.Usage = func() { c.Ui.Output(c.Help()) }
cmdFlags.BoolVar(&trace, "trace", false, "")
if err := cmdFlags.Parse(args); err != nil {
c.logger.Error("failed to parse flags:", "error", err)
return 1
}
if trace {
c.logger.SetLevel(hclog.Trace)
} else if c.verbose {
c.logger.SetLevel(hclog.Debug)
}
args = cmdFlags.Args()
numArgs := len(args)
if numArgs < 1 {
c.logger.Error("expected at least 1 args (device binary)", "args", args)
return 1
} else if numArgs > 2 {
c.logger.Error("expected at most 2 args (device binary and config file)", "args", args)
return 1
}
binary := args[0]
var config []byte
if numArgs == 2 {
var err error
config, err = ioutil.ReadFile(args[1])
if err != nil {
c.logger.Error("failed to read config file", "error", err)
return 1
}
c.logger.Trace("read config", "config", string(config))
}
// Get the plugin
dev, cleanup, err := c.getDevicePlugin(binary)
if err != nil {
c.logger.Error("failed to launch device plugin", "error", err)
return 1
}
defer cleanup()
c.dev = dev
spec, err := c.getSpec()
if err != nil {
c.logger.Error("failed to get config spec", "error", err)
return 1
}
c.spec = spec
if err := c.setConfig(spec, config); err != nil {
c.logger.Error("failed to set config", "error", err)
return 1
}
if err := c.startRepl(); err != nil {
c.logger.Error("error interacting with plugin", "error", err)
return 1
}
return 0
}
func (c *Device) getDevicePlugin(binary string) (device.DevicePlugin, func(), error) {
// Launch the plugin
client := plugin.NewClient(&plugin.ClientConfig{
HandshakeConfig: base.Handshake,
Plugins: map[string]plugin.Plugin{
base.PluginTypeBase: &base.PluginBase{},
base.PluginTypeDevice: &device.PluginDevice{},
},
Cmd: exec.Command(binary),
AllowedProtocols: []plugin.Protocol{plugin.ProtocolGRPC},
Logger: c.logger,
})
// Connect via RPC
rpcClient, err := client.Client()
if err != nil {
client.Kill()
return nil, nil, err
}
// Request the plugin
raw, err := rpcClient.Dispense(base.PluginTypeDevice)
if err != nil {
client.Kill()
return nil, nil, err
}
// We should have a KV store now! This feels like a normal interface
// implementation but is in fact over an RPC connection.
dev := raw.(device.DevicePlugin)
return dev, func() { client.Kill() }, nil
}
func (c *Device) getSpec() (hcldec.Spec, error) {
// Get the schema so we can parse the config
spec, err := c.dev.ConfigSchema()
if err != nil {
return nil, fmt.Errorf("failed to get config schema: %v", err)
}
c.logger.Trace("device spec", "spec", hclog.Fmt("% #v", pretty.Formatter(spec)))
// Convert the schema
schema, diag := hclspec.Convert(spec)
if diag.HasErrors() {
errStr := "failed to convert HCL schema: "
for _, err := range diag.Errs() {
errStr = fmt.Sprintf("%s\n* %s", errStr, err.Error())
}
return nil, errors.New(errStr)
}
return schema, nil
}
func (c *Device) setConfig(spec hcldec.Spec, config []byte) error {
// Parse the config into hcl
configVal, err := hclConfigToInterface(config)
if err != nil {
return err
}
c.logger.Trace("raw hcl config", "config", hclog.Fmt("% #v", pretty.Formatter(configVal)))
ctx := &hcl2.EvalContext{
Functions: shared.GetStdlibFuncs(),
}
val, diag := shared.ParseHclInterface(configVal, spec, ctx)
if diag.HasErrors() {
errStr := "failed to parse config"
for _, err := range diag.Errs() {
errStr = fmt.Sprintf("%s\n* %s", errStr, err.Error())
}
return errors.New(errStr)
}
c.logger.Trace("parsed hcl config", "config", hclog.Fmt("% #v", pretty.Formatter(val)))
cdata, err := msgpack.Marshal(val, val.Type())
if err != nil {
return err
}
c.logger.Trace("msgpack config", "config", string(cdata))
if err := c.dev.SetConfig(cdata); err != nil {
return err
}
return nil
}
func hclConfigToInterface(config []byte) (interface{}, error) {
if len(config) == 0 {
return map[string]interface{}{}, nil
}
// Parse as we do in the jobspec parser
root, err := hcl.Parse(string(config))
if err != nil {
return nil, fmt.Errorf("failed to hcl parse the config: %v", err)
}
// Top-level item should be a list
list, ok := root.Node.(*ast.ObjectList)
if !ok {
return nil, fmt.Errorf("root should be an object")
}
var m map[string]interface{}
if err := hcl.DecodeObject(&m, list.Items[0]); err != nil {
return nil, fmt.Errorf("failed to decode object: %v", err)
}
return m["config"], nil
}
func (c *Device) startRepl() error {
// Start the output goroutine
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
fingerprint := make(chan context.Context)
stats := make(chan context.Context)
reserve := make(chan []string)
go c.replOutput(ctx, fingerprint, stats, reserve)
c.Ui.Output("> Availabile commands are: exit(), fingerprint(), stop_fingerprint(), stats(), stop_stats(), reserve(id1, id2, ...)")
var fingerprintCtx, statsCtx context.Context
var fingerprintCancel, statsCancel context.CancelFunc
for {
in, err := c.Ui.Ask("> ")
if err != nil {
return err
}
switch {
case in == "exit()":
return nil
case in == "fingerprint()":
if fingerprintCtx != nil {
continue
}
fingerprintCtx, fingerprintCancel = context.WithCancel(ctx)
fingerprint <- fingerprintCtx
case in == "stop_fingerprint()":
if fingerprintCtx == nil {
continue
}
fingerprintCancel()
fingerprintCtx = nil
case in == "stats()":
if statsCtx != nil {
continue
}
statsCtx, statsCancel = context.WithCancel(ctx)
stats <- statsCtx
case in == "stop_stats()":
if statsCtx == nil {
continue
}
statsCancel()
statsCtx = nil
case strings.HasPrefix(in, "reserve(") && strings.HasSuffix(in, ")"):
listString := strings.TrimSuffix(strings.TrimPrefix(in, "reserve("), ")")
ids := strings.Split(strings.TrimSpace(listString), ",")
reserve <- ids
default:
c.Ui.Error(fmt.Sprintf("> Unknown command %q", in))
}
}
return nil
}
func (c *Device) replOutput(ctx context.Context, startFingerprint, startStats <-chan context.Context, reserve <-chan []string) {
var fingerprint <-chan *device.FingerprintResponse
var stats <-chan *device.StatsResponse
for {
select {
case <-ctx.Done():
return
case ctx := <-startFingerprint:
var err error
fingerprint, err = c.dev.Fingerprint(ctx)
if err != nil {
c.Ui.Error(fmt.Sprintf("fingerprint: %s", err))
os.Exit(1)
}
case resp, ok := <-fingerprint:
if !ok {
c.Ui.Output("> fingerprint: fingerprint output closed")
fingerprint = nil
continue
}
if resp == nil {
c.Ui.Warn("> fingerprint: received nil result")
os.Exit(1)
}
c.Ui.Output(fmt.Sprintf("> fingerprint: % #v", pretty.Formatter(resp)))
case ctx := <-startStats:
var err error
stats, err = c.dev.Stats(ctx)
if err != nil {
c.Ui.Error(fmt.Sprintf("stats: %s", err))
os.Exit(1)
}
case resp, ok := <-stats:
if !ok {
c.Ui.Output("> stats: stats output closed")
stats = nil
continue
}
if resp == nil {
c.Ui.Warn("> stats: received nil result")
os.Exit(1)
}
c.Ui.Output(fmt.Sprintf("> stats: % #v", pretty.Formatter(resp)))
case ids := <-reserve:
resp, err := c.dev.Reserve(ids)
if err != nil {
c.Ui.Warn(fmt.Sprintf("> reserve(%s): %v", strings.Join(ids, ", "), err))
} else {
c.Ui.Output(fmt.Sprintf("> reserve(%s): % #v", strings.Join(ids, ", "), pretty.Formatter(resp)))
}
}
}
}

View file

@ -0,0 +1,39 @@
package command
import (
"flag"
"strings"
hclog "github.com/hashicorp/go-hclog"
"github.com/mitchellh/cli"
)
type Meta struct {
Ui cli.Ui
logger hclog.Logger
verbose bool
}
func NewMeta(ui cli.Ui, logger hclog.Logger) Meta {
return Meta{
Ui: ui,
logger: logger,
}
}
func (m *Meta) FlagSet(n string) *flag.FlagSet {
f := flag.NewFlagSet(n, flag.ContinueOnError)
f.BoolVar(&m.verbose, "verbose", false, "Toggle verbose output")
return f
}
// generalOptionsUsage return the help string for the global options
func generalOptionsUsage() string {
helpText := `
-verbose
Enables verbose logging.
`
return strings.TrimSpace(helpText)
}

View file

@ -0,0 +1,41 @@
package main
import (
"os"
hclog "github.com/hashicorp/go-hclog"
"github.com/hashicorp/nomad/plugins/shared/cmd/launcher/command"
"github.com/mitchellh/cli"
)
const (
NomadPluginLauncherCli = "nomad-plugin-launcher"
NomadPluginLauncherCliVersion = "0.0.1"
)
func main() {
ui := &cli.BasicUi{
Reader: os.Stdin,
Writer: os.Stdout,
ErrorWriter: os.Stderr,
}
logger := hclog.New(&hclog.LoggerOptions{
Name: NomadPluginLauncherCli,
Output: &cli.UiWriter{Ui: ui},
})
c := cli.NewCLI(NomadPluginLauncherCli, NomadPluginLauncherCliVersion)
c.Args = os.Args[1:]
meta := command.NewMeta(ui, logger)
c.Commands = map[string]cli.CommandFactory{
"device": command.DeviceCommandFactory(meta),
}
exitStatus, err := c.Run()
if err != nil {
logger.Error("command exited with non-zero status", "status", exitStatus, "error", err)
}
os.Exit(exitStatus)
}

View file

@ -0,0 +1,170 @@
package hclspec
// ObjectSpec wraps the object and returns a spec.
func ObjectSpec(obj *Object) *Spec {
return &Spec{
Block: &Spec_Object{
Object: obj,
},
}
}
// ArraySpec wraps the array and returns a spec.
func ArraySpec(array *Array) *Spec {
return &Spec{
Block: &Spec_Array{
Array: array,
},
}
}
// AttrSpec wraps the attr and returns a spec.
func AttrSpec(attr *Attr) *Spec {
return &Spec{
Block: &Spec_Attr{
Attr: attr,
},
}
}
// BlockSpec wraps the block and returns a spec.
func BlockSpec(block *Block) *Spec {
return &Spec{
Block: &Spec_BlockValue{
BlockValue: block,
},
}
}
// BlockListSpec wraps the block list and returns a spec.
func BlockListSpec(blockList *BlockList) *Spec {
return &Spec{
Block: &Spec_BlockList{
BlockList: blockList,
},
}
}
// BlockSetSpec wraps the block set and returns a spec.
func BlockSetSpec(blockSet *BlockSet) *Spec {
return &Spec{
Block: &Spec_BlockSet{
BlockSet: blockSet,
},
}
}
// BlockMapSpec wraps the block map and returns a spec.
func BlockMapSpec(blockMap *BlockMap) *Spec {
return &Spec{
Block: &Spec_BlockMap{
BlockMap: blockMap,
},
}
}
// DefaultSpec wraps the default and returns a spec.
func DefaultSpec(d *Default) *Spec {
return &Spec{
Block: &Spec_Default{
Default: d,
},
}
}
// LiteralSpec wraps the literal and returns a spec.
func LiteralSpec(l *Literal) *Spec {
return &Spec{
Block: &Spec_Literal{
Literal: l,
},
}
}
// NewObject returns a new object spec.
func NewObject(attrs map[string]*Spec) *Spec {
return ObjectSpec(&Object{
Attributes: attrs,
})
}
// NewAttr returns a new attribute spec.
func NewAttr(name, attrType string, required bool) *Spec {
return AttrSpec(&Attr{
Name: name,
Type: attrType,
Required: required,
})
}
// NewBlock returns a new block spec.
func NewBlock(name string, required bool, nested *Spec) *Spec {
return BlockSpec(&Block{
Name: name,
Required: required,
Nested: nested,
})
}
// NewBlockList returns a new block list spec that has no limits.
func NewBlockList(name string, nested *Spec) *Spec {
return NewBlockListLimited(name, 0, 0, nested)
}
// NewBlockListLimited returns a new block list spec that limits the number of
// blocks.
func NewBlockListLimited(name string, min, max uint64, nested *Spec) *Spec {
return BlockListSpec(&BlockList{
Name: name,
MinItems: min,
MaxItems: max,
Nested: nested,
})
}
// NewBlockSet returns a new block set spec that has no limits.
func NewBlockSet(name string, nested *Spec) *Spec {
return NewBlockSetLimited(name, 0, 0, nested)
}
// NewBlockSetLimited returns a new block set spec that limits the number of
// blocks.
func NewBlockSetLimited(name string, min, max uint64, nested *Spec) *Spec {
return BlockSetSpec(&BlockSet{
Name: name,
MinItems: min,
MaxItems: max,
Nested: nested,
})
}
// NewBlockMap returns a new block map spec.
func NewBlockMap(name string, labels []string, nested *Spec) *Spec {
return BlockMapSpec(&BlockMap{
Name: name,
Labels: labels,
Nested: nested,
})
}
// NewLiteral returns a new literal spec.
func NewLiteral(value string) *Spec {
return LiteralSpec(&Literal{
Value: value,
})
}
// NewDefault returns a new default spec.
func NewDefault(primary, defaultValue *Spec) *Spec {
return DefaultSpec(&Default{
Primary: primary,
Default: defaultValue,
})
}
// NewArray returns a new array spec.
func NewArray(values []*Spec) *Spec {
return ArraySpec(&Array{
Values: values,
})
}

View file

@ -81,32 +81,35 @@ export default RESTAdapter.extend({
//
// This is the original implementation of _buildURL
// without the pluralization of modelName
urlForFindRecord(id, modelName) {
let path;
let url = [];
let host = get(this, 'host');
let prefix = this.urlPrefix();
if (modelName) {
path = modelName.camelize();
if (path) {
url.push(path);
}
}
if (id) {
url.push(encodeURIComponent(id));
}
if (prefix) {
url.unshift(prefix);
}
url = url.join('/');
if (!host && url && url.charAt(0) !== '/') {
url = '/' + url;
}
return url;
},
urlForFindRecord: urlForRecord,
urlForUpdateRecord: urlForRecord,
});
function urlForRecord(id, modelName) {
let path;
let url = [];
let host = get(this, 'host');
let prefix = this.urlPrefix();
if (modelName) {
path = modelName.camelize();
if (path) {
url.push(path);
}
}
if (id) {
url.push(encodeURIComponent(id));
}
if (prefix) {
url.unshift(prefix);
}
url = url.join('/');
if (!host && url && url.charAt(0) !== '/') {
url = '/' + url;
}
return url;
}

View file

@ -0,0 +1,29 @@
import Watchable from './watchable';
export default Watchable.extend({
promote(deployment) {
const id = deployment.get('id');
const url = urlForAction(this.urlForFindRecord(id, 'deployment'), '/promote');
return this.ajax(url, 'POST', {
data: {
DeploymentId: id,
All: true,
},
});
},
});
// The deployment action API endpoints all end with the ID
// /deployment/:action/:deployment_id instead of /deployment/:deployment_id/:action
function urlForAction(url, extension = '') {
const [path, params] = url.split('?');
const pathParts = path.split('/');
const idPart = pathParts.pop();
let newUrl = `${pathParts.join('/')}${extension}/${idPart}`;
if (params) {
newUrl += `?${params}`;
}
return newUrl;
}

View file

@ -33,6 +33,12 @@ export default Watchable.extend({
return associateNamespace(url, namespace);
},
urlForUpdateRecord(id, type, hash) {
const [name, namespace] = JSON.parse(id);
let url = this._super(name, type, hash);
return associateNamespace(url, namespace);
},
xhrKey(url, method, options = {}) {
const plainKey = this._super(...arguments);
const namespace = options.data && options.data.namespace;
@ -59,6 +65,51 @@ export default Watchable.extend({
const url = this.urlForFindRecord(job.get('id'), 'job');
return this.ajax(url, 'DELETE');
},
parse(spec) {
const url = addToPath(this.urlForFindAll('job'), '/parse');
return this.ajax(url, 'POST', {
data: {
JobHCL: spec,
Canonicalize: true,
},
});
},
plan(job) {
const jobId = job.get('id');
const store = this.get('store');
const url = addToPath(this.urlForFindRecord(jobId, 'job'), '/plan');
return this.ajax(url, 'POST', {
data: {
Job: job.get('_newDefinitionJSON'),
Diff: true,
},
}).then(json => {
json.ID = jobId;
store.pushPayload('job-plan', { jobPlans: [json] });
return store.peekRecord('job-plan', jobId);
});
},
// Running a job doesn't follow REST create semantics so it's easier to
// treat it as an action.
run(job) {
return this.ajax(this.urlForCreateRecord('job'), 'POST', {
data: {
Job: job.get('_newDefinitionJSON'),
},
});
},
update(job) {
return this.ajax(this.urlForUpdateRecord(job.get('id'), 'job'), 'POST', {
data: {
Job: job.get('_newDefinitionJSON'),
},
});
},
});
function associateNamespace(url, namespace) {

View file

@ -0,0 +1,102 @@
import Component from '@ember/component';
import { assert } from '@ember/debug';
import { inject as service } from '@ember/service';
import { computed } from '@ember/object';
import { task } from 'ember-concurrency';
import messageFromAdapterError from 'nomad-ui/utils/message-from-adapter-error';
import localStorageProperty from 'nomad-ui/utils/properties/local-storage';
export default Component.extend({
store: service(),
config: service(),
'data-test-job-editor': true,
job: null,
onSubmit() {},
context: computed({
get() {
return this.get('_context');
},
set(key, value) {
const allowedValues = ['new', 'edit'];
assert(`context must be one of: ${allowedValues.join(', ')}`, allowedValues.includes(value));
this.set('_context', value);
return value;
},
}),
_context: null,
parseError: null,
planError: null,
runError: null,
planOutput: null,
showPlanMessage: localStorageProperty('nomadMessageJobPlan', true),
showEditorMessage: localStorageProperty('nomadMessageJobEditor', true),
stage: computed('planOutput', function() {
return this.get('planOutput') ? 'plan' : 'editor';
}),
plan: task(function*() {
this.reset();
try {
yield this.get('job').parse();
} catch (err) {
const error = messageFromAdapterError(err) || 'Could not parse input';
this.set('parseError', error);
this.scrollToError();
return;
}
try {
const plan = yield this.get('job').plan();
this.set('planOutput', plan);
} catch (err) {
const error = messageFromAdapterError(err) || 'Could not plan job';
this.set('planError', error);
this.scrollToError();
}
}).drop(),
submit: task(function*() {
try {
if (this.get('context') === 'new') {
yield this.get('job').run();
} else {
yield this.get('job').update();
}
const id = this.get('job.plainId');
const namespace = this.get('job.namespace.name') || 'default';
this.reset();
// Treat the job as ephemeral and only provide ID parts.
this.get('onSubmit')(id, namespace);
} catch (err) {
const error = messageFromAdapterError(err) || 'Could not submit job';
this.set('runError', error);
this.set('planOutput', null);
this.scrollToError();
}
}),
reset() {
this.set('planOutput', null);
this.set('planError', null);
this.set('parseError', null);
this.set('runError', null);
},
scrollToError() {
if (!this.get('config.isTest')) {
window.scrollTo(0, 0);
}
},
});

View file

@ -1,8 +1,27 @@
import Component from '@ember/component';
import { task } from 'ember-concurrency';
import messageFromAdapterError from 'nomad-ui/utils/message-from-adapter-error';
export default Component.extend({
job: null,
tagName: '',
handleError() {},
isShowingDeploymentDetails: false,
promote: task(function*() {
try {
yield this.get('job.latestDeployment.content').promote();
} catch (err) {
let message = messageFromAdapterError(err);
if (!message || message === 'Forbidden') {
message = 'Your ACL token does not grant permission to promote deployments.';
}
this.get('handleError')({
title: 'Could Not Promote Deployment',
description: message,
});
}
}),
});

View file

@ -1,4 +1,6 @@
import Component from '@ember/component';
import { task } from 'ember-concurrency';
import messageFromAdapterError from 'nomad-ui/utils/message-from-adapter-error';
export default Component.extend({
tagName: '',
@ -8,16 +10,42 @@ export default Component.extend({
handleError() {},
actions: {
stopJob() {
this.get('job')
.stop()
.catch(() => {
this.get('handleError')({
title: 'Could Not Stop Job',
description: 'Your ACL token does not grant permission to stop jobs.',
});
});
},
},
stopJob: task(function*() {
try {
const job = this.get('job');
yield job.stop();
// Eagerly update the job status to avoid flickering
this.job.set('status', 'dead');
} catch (err) {
this.get('handleError')({
title: 'Could Not Stop Job',
description: 'Your ACL token does not grant permission to stop jobs.',
});
}
}),
startJob: task(function*() {
const job = this.get('job');
const definition = yield job.fetchRawDefinition();
delete definition.Stop;
job.set('_newDefinition', JSON.stringify(definition));
try {
yield job.parse();
yield job.update();
// Eagerly update the job status to avoid flickering
job.set('status', 'running');
} catch (err) {
let message = messageFromAdapterError(err);
if (!message || message === 'Forbidden') {
message = 'Your ACL token does not grant permission to stop jobs.';
}
this.get('handleError')({
title: 'Could Not Start Job',
description: message,
});
}
}),
});

View file

@ -0,0 +1,10 @@
import Component from '@ember/component';
import { or } from '@ember/object/computed';
export default Component.extend({
// Either provide a taskGroup or a failedTGAlloc
taskGroup: null,
failedTGAlloc: null,
placementFailures: or('taskGroup.placementFailures', 'failedTGAlloc'),
});

View file

@ -1,5 +1,6 @@
import Component from '@ember/component';
import { equal } from '@ember/object/computed';
import RSVP from 'rsvp';
export default Component.extend({
classNames: ['two-step-button'],
@ -8,6 +9,7 @@ export default Component.extend({
cancelText: '',
confirmText: '',
confirmationMessage: '',
awaitingConfirmation: false,
onConfirm() {},
onCancel() {},
@ -22,5 +24,10 @@ export default Component.extend({
promptForConfirmation() {
this.set('state', 'prompt');
},
confirm() {
RSVP.resolve(this.get('onConfirm')()).then(() => {
this.send('setToIdle');
});
},
},
});

View file

@ -4,4 +4,22 @@ import { alias } from '@ember/object/computed';
export default Controller.extend(WithNamespaceResetting, {
job: alias('model.job'),
definition: alias('model.definition'),
isEditing: false,
edit() {
this.get('job').set('_newDefinition', JSON.stringify(this.get('definition'), null, 2));
this.set('isEditing', true);
},
onCancel() {
this.set('isEditing', false);
},
onSubmit(id, namespace) {
this.transitionToRoute('jobs.job', id, {
queryParams: { jobNamespace: namespace },
});
},
});

View file

@ -0,0 +1,9 @@
import Controller from '@ember/controller';
export default Controller.extend({
onSubmit(id, namespace) {
this.transitionToRoute('jobs.job', id, {
queryParams: { jobNamespace: namespace },
});
},
});

View file

@ -1,5 +1,6 @@
import { alias, equal } from '@ember/object/computed';
import { computed } from '@ember/object';
import { assert } from '@ember/debug';
import Model from 'ember-data/model';
import attr from 'ember-data/attr';
import { belongsTo, hasMany } from 'ember-data/relationships';
@ -58,4 +59,9 @@ export default Model.extend({
return classMap[this.get('status')] || 'is-dark';
}),
promote() {
assert('A deployment needs to requirePromotion to be promoted', this.get('requiresPromotion'));
return this.store.adapterFor('deployment').promote(this);
},
});

View file

@ -0,0 +1,8 @@
import Model from 'ember-data/model';
import attr from 'ember-data/attr';
import { fragmentArray } from 'ember-data-model-fragments/attributes';
export default Model.extend({
diff: attr(),
failedTGAllocs: fragmentArray('placement-failure', { defaultValue: () => [] }),
});

View file

@ -4,6 +4,8 @@ import Model from 'ember-data/model';
import attr from 'ember-data/attr';
import { belongsTo, hasMany } from 'ember-data/relationships';
import { fragmentArray } from 'ember-data-model-fragments/attributes';
import RSVP from 'rsvp';
import { assert } from '@ember/debug';
const JOB_TYPES = ['service', 'batch', 'system'];
@ -191,6 +193,68 @@ export default Model.extend({
return this.store.adapterFor('job').stop(this);
},
plan() {
assert('A job must be parsed before planned', this.get('_newDefinitionJSON'));
return this.store.adapterFor('job').plan(this);
},
run() {
assert('A job must be parsed before ran', this.get('_newDefinitionJSON'));
return this.store.adapterFor('job').run(this);
},
update() {
assert('A job must be parsed before updated', this.get('_newDefinitionJSON'));
return this.store.adapterFor('job').update(this);
},
parse() {
const definition = this.get('_newDefinition');
let promise;
try {
// If the definition is already JSON then it doesn't need to be parsed.
const json = JSON.parse(definition);
this.set('_newDefinitionJSON', json);
// You can't set the ID of a record that already exists
if (this.get('isNew')) {
this.setIdByPayload(json);
}
promise = RSVP.resolve(definition);
} catch (err) {
// If the definition is invalid JSON, assume it is HCL. If it is invalid
// in anyway, the parse endpoint will throw an error.
promise = this.store
.adapterFor('job')
.parse(this.get('_newDefinition'))
.then(response => {
this.set('_newDefinitionJSON', response);
this.setIdByPayload(response);
});
}
return promise;
},
setIdByPayload(payload) {
const namespace = payload.Namespace || 'default';
const id = payload.Name;
this.set('plainId', id);
this.set('id', JSON.stringify([id, namespace]));
const namespaceRecord = this.store.peekRecord('namespace', namespace);
if (namespaceRecord) {
this.set('namespace', namespaceRecord);
}
},
resetId() {
this.set('id', JSON.stringify([this.get('plainId'), this.get('namespace.name') || 'default']));
},
statusClass: computed('status', function() {
const classMap = {
pending: 'is-pending',
@ -206,4 +270,13 @@ export default Model.extend({
// Lazily decode the base64 encoded payload
return window.atob(this.get('payload') || '');
}),
// An arbitrary HCL or JSON string that is used by the serializer to plan
// and run this job. Used for both new job models and saved job models.
_newDefinition: attr('string'),
// The new definition may be HCL, in which case the API will need to parse the
// spec first. In order to preserve both the original HCL and the parsed response
// that will be submitted to the create job endpoint, another prop is necessary.
_newDefinitionJSON: attr('string'),
});

View file

@ -8,6 +8,7 @@ const Router = EmberRouter.extend({
Router.map(function() {
this.route('jobs', function() {
this.route('run');
this.route('job', { path: '/:job_name' }, function() {
this.route('task-group', { path: '/:name' });
this.route('definition');
@ -15,6 +16,7 @@ Router.map(function() {
this.route('deployments');
this.route('evaluations');
this.route('allocations');
this.route('edit');
});
});

View file

@ -8,4 +8,13 @@ export default Route.extend({
definition,
}));
},
resetController(controller, isExiting) {
if (isExiting) {
const job = controller.get('job');
job.rollbackAttributes();
job.resetId();
controller.set('isEditing', false);
}
},
});

26
ui/app/routes/jobs/run.js Normal file
View file

@ -0,0 +1,26 @@
import Route from '@ember/routing/route';
import { inject as service } from '@ember/service';
export default Route.extend({
store: service(),
system: service(),
breadcrumbs: [
{
label: 'Run',
args: ['jobs.run'],
},
],
model() {
return this.get('store').createRecord('job', {
namespace: this.get('system.activeNamespace'),
});
},
resetController(controller, isExiting) {
if (isExiting) {
controller.get('model').deleteRecord();
}
},
});

View file

@ -0,0 +1,12 @@
import { get } from '@ember/object';
import { assign } from '@ember/polyfills';
import ApplicationSerializer from './application';
export default ApplicationSerializer.extend({
normalize(typeHash, hash) {
hash.FailedTGAllocs = Object.keys(hash.FailedTGAllocs || {}).map(key => {
return assign({ Name: key }, get(hash, `FailedTGAllocs.${key}`) || {});
});
return this._super(...arguments);
},
});

View file

@ -9,13 +9,13 @@ export default Service.extend({
secret: computed({
get() {
return window.sessionStorage.nomadTokenSecret;
return window.localStorage.nomadTokenSecret;
},
set(key, value) {
if (value == null) {
window.sessionStorage.removeItem('nomadTokenSecret');
window.localStorage.removeItem('nomadTokenSecret');
} else {
window.sessionStorage.nomadTokenSecret = value;
window.localStorage.nomadTokenSecret = value;
}
return value;

View file

@ -18,6 +18,6 @@ export default Service.extend({
},
setIndexFor(url, value) {
list[url] = value;
list[url] = +value;
},
});

View file

@ -4,6 +4,10 @@ $dark-bright: lighten($dark, 15%);
height: auto;
}
.CodeMirror-scroll {
min-height: 500px;
}
.cm-s-hashi,
.cm-s-hashi-read-only {
&.CodeMirror {
@ -39,7 +43,7 @@ $dark-bright: lighten($dark, 15%);
}
span.cm-comment {
color: $grey-light;
color: $grey;
}
span.cm-string,

View file

@ -1,5 +1,5 @@
.page-layout {
height: 100%;
min-height: 100%;
display: flex;
flex-direction: column;

View file

@ -1,34 +1,35 @@
// Utils
@import "./utils/reset.scss";
@import "./utils/z-indices";
@import "./utils/product-colors";
@import "./utils/bumper";
@import './utils/reset.scss';
@import './utils/z-indices';
@import './utils/product-colors';
@import './utils/bumper';
@import './utils/layout';
// Start with Bulma variables as a foundation
@import "bulma/sass/utilities/initial-variables";
@import 'bulma/sass/utilities/initial-variables';
// Override variables where appropriate
@import "./core/variables.scss";
@import './core/variables.scss';
// Bring in the rest of Bulma
@import "bulma/bulma";
@import 'bulma/bulma';
// Override Bulma details where appropriate
@import "./core/buttons";
@import "./core/breadcrumb";
@import "./core/columns";
@import "./core/forms";
@import "./core/icon";
@import "./core/level";
@import "./core/menu";
@import "./core/message";
@import "./core/navbar";
@import "./core/notification";
@import "./core/pagination";
@import "./core/progress";
@import "./core/section";
@import "./core/table";
@import "./core/tabs";
@import "./core/tag";
@import "./core/title";
@import "./core/typography";
@import './core/buttons';
@import './core/breadcrumb';
@import './core/columns';
@import './core/forms';
@import './core/icon';
@import './core/level';
@import './core/menu';
@import './core/message';
@import './core/navbar';
@import './core/notification';
@import './core/pagination';
@import './core/progress';
@import './core/section';
@import './core/table';
@import './core/tabs';
@import './core/tag';
@import './core/title';
@import './core/typography';

View file

@ -0,0 +1,3 @@
.is-associative {
margin-top: -0.75em;
}

View file

@ -15,7 +15,7 @@
<div class="chart-tooltip {{if isActive "active" "inactive"}}" style={{tooltipStyle}}>
<ol>
{{#each _data as |datum index|}}
<li class="{{if (eq datum.index activeDatum.index) "active"}}">
<li class="{{if (eq datum.label activeDatum.label) "active"}}">
<span class="label {{if (eq datum.value 0) "is-empty"}}">
<span class="color-swatch {{if datum.className datum.className (concat "swatch-" index)}}" />
{{datum.label}}

Before

Width:  |  Height:  |  Size: 801 B

After

Width:  |  Height:  |  Size: 801 B

View file

@ -20,3 +20,21 @@
</h1>
</div>
{{/freestyle-usage}}
{{#freestyle-usage "two-step-button-loading" title="Two Step Button Loading State"}}
<div class="mock-spacing">
<h1 class="title">
This is a page title
{{two-step-button
idleText="Scary Action"
cancelText="Nvm"
confirmText="Yep"
confirmationMessage="Wait, really? Like...seriously?"
awaitingConfirmation=true
state="prompt"}}
</h1>
</div>
{{/freestyle-usage}}
{{#freestyle-annotation}}
<strong>Note:</strong> the <code>state</code> property is internal state and only used here to bypass the idle state for demonstration purposes.
{{/freestyle-annotation}}

View file

@ -80,12 +80,12 @@
</span>
Task: "{{task.Name}}"
{{#if task.Annotations}}
({{#each task.Annotations as |annotation index|}}
({{~#each task.Annotations as |annotation index|}}
<span class="{{css-class annotation}}">{{annotation}}</span>
{{#unless (eq index (dec annotations.length))}},{{/unless}}
{{/each}})
{{#unless (eq index (dec task.Annotations.length))}},{{/unless}}
{{/each~}})
{{/if}}
{{#if (or verbose (eq (lowercase task.Type "edited")))}}
{{#if (or verbose (eq (lowercase task.Type) "edited"))}}
{{job-diff-fields-and-objects fields=task.Fields objects=task.Objects}}
{{/if}}
</div>

View file

@ -0,0 +1,95 @@
{{#if parseError}}
<div data-test-parse-error class="notification is-danger">
<h3 class="title is-4" data-test-parse-error-title>Parse Error</h3>
<p data-test-parse-error-message>{{parseError}}</p>
</div>
{{/if}}
{{#if planError}}
<div data-test-plan-error class="notification is-danger">
<h3 class="title is-4" data-test-plan-error-title>Plan Error</h3>
<p data-test-plan-error-message>{{planError}}</p>
</div>
{{/if}}
{{#if runError}}
<div data-test-run-error class="notification is-danger">
<h3 class="title is-4" data-test-run-error-title>Run Error</h3>
<p data-test-run-error-message>{{runError}}</p>
</div>
{{/if}}
{{#if (eq stage "editor")}}
{{#if (and showEditorMessage (eq context "new"))}}
<div class="notification is-info">
<div class="columns">
<div class="column">
<h3 class="title is-4" data-test-editor-help-title>Run a Job</h3>
<p data-test-editor-help-message>Paste or author HCL or JSON to submit to your cluster. A plan will be requested before the job is submitted.</p>
</div>
<div class="column is-centered is-minimum">
<button class="button is-info" onclick={{toggle-action "showEditorMessage" this}} data-test-editor-help-dismiss>Okay</button>
</div>
</div>
</div>
{{/if}}
<div class="boxed-section">
<div class="boxed-section-head">
Job Definition
{{#if cancelable}}
<button class="button is-light is-compact pull-right" onclick={{action onCancel}} data-test-cancel-editing>Cancel</button>
{{/if}}
</div>
<div class="boxed-section-body is-full-bleed">
{{ivy-codemirror
data-test-editor
value=(or job._newDefinition jobSpec)
valueUpdated=(action (mut job._newDefinition))
options=(hash
mode="javascript"
theme="hashi"
tabSize=2
lineNumbers=true
)}}
</div>
</div>
<div class="content is-associative">
<button class="button is-primary {{if plan.isRunning "is-loading"}}" type="button" onclick={{perform plan}} disabled={{or plan.isRunning (not job._newDefinition)}} data-test-plan>Plan</button>
</div>
{{/if}}
{{#if (eq stage "plan")}}
{{#if showPlanMessage}}
<div class="notification is-info">
<div class="columns">
<div class="column">
<h3 class="title is-4" data-test-plan-help-title>Job Plan</h3>
<p data-test-plan-help-message>This is the impact running this job will have on your cluster.</p>
</div>
<div class="column is-centered is-minimum">
<button class="button is-info" onclick={{toggle-action "showPlanMessage" this}} data-test-plan-help-dismiss>Okay</button>
</div>
</div>
</div>
{{/if}}
<div class="boxed-section">
<div class="boxed-section-head">Job Plan</div>
<div class="boxed-section-body is-dark">
{{job-diff data-test-plan-output diff=planOutput.diff verbose=false}}
</div>
</div>
<div class="boxed-section {{if planOutput.failedTGAllocs "is-warning" "is-primary"}}" data-test-dry-run-message>
<div class="boxed-section-head" data-test-dry-run-title>Scheduler dry-run</div>
<div class="boxed-section-body" data-test-dry-run-body>
{{#if planOutput.failedTGAllocs}}
{{#each planOutput.failedTGAllocs as |placementFailure|}}
{{placement-failure failedTGAlloc=placementFailure}}
{{/each}}
{{else}}
All tasks successfully allocated.
{{/if}}
</div>
</div>
<div class="content is-associative">
<button class="button is-primary {{if submit.isRunning "is-loading"}}" type="button" onclick={{perform submit}} disabled={{submit.isRunning}} data-test-run>Run</button>
<button class="button is-light" type="button" onclick={{action reset}} data-test-cancel>Cancel</button>
</div>
{{/if}}

View file

@ -13,7 +13,12 @@
{{job.latestDeployment.status}}
</span>
{{#if job.latestDeployment.requiresPromotion}}
<span class="tag bumper-left is-warning no-text-transform">Deployment is running but requires promotion</span>
<button
data-test-promote-canary
type="button"
class="button is-warning is-small pull-right {{if promote.isRunning "is-loading"}}"
disabled={{promote.isRunning}}
onclick={{perform promote}}>Promote Canary</button>
{{/if}}
</div>
</div>

View file

@ -2,7 +2,7 @@
<div class="boxed-section-head">
Recent Allocations
</div>
<div class="boxed-section-body is-full-bleed">
<div class="boxed-section-body {{if job.allocations.length "is-full-bleed"}}">
{{#if job.allocations.length}}
{{#list-table
source=sortedAllocations

View file

@ -39,7 +39,7 @@
class="split-view" as |chart|}}
<ol data-test-legend class="legend">
{{#each chart.data as |datum index|}}
<li class="{{datum.className}} {{if (eq datum.index chart.activeDatum.index) "is-active"}} {{if (eq datum.value 0) "is-empty"}}">
<li class="{{datum.className}} {{if (eq datum.label chart.activeDatum.label) "is-active"}} {{if (eq datum.value 0) "is-empty"}}">
<span class="color-swatch {{if datum.className datum.className (concat "swatch-" index)}}" />
<span class="value" data-test-legend-value="{{datum.className}}">{{datum.value}}</span>
<span class="label">

View file

@ -9,6 +9,16 @@
cancelText="Cancel"
confirmText="Yes, Stop"
confirmationMessage="Are you sure you want to stop this job?"
onConfirm=(action "stopJob")}}
awaitingConfirmation=stopJob.isRunning
onConfirm=(perform stopJob)}}
{{else}}
{{two-step-button
data-test-start
idleText="Start"
cancelText="Cancel"
confirmText="Yes, Start"
confirmationMessage="Are you sure you want to start this job?"
awaitingConfirmation=startJob.isRunning
onConfirm=(perform startJob)}}
{{/if}}
</h1>

View file

@ -17,7 +17,7 @@
{{job-page/parts/placement-failures job=job}}
{{job-page/parts/latest-deployment job=job}}
{{job-page/parts/latest-deployment job=job handleError=(action "handleError")}}
{{job-page/parts/task-groups
job=job

View file

@ -1,7 +1,7 @@
{{#if taskGroup.placementFailures}}
{{#with taskGroup.placementFailures as |failures|}}
{{#if placementFailures}}
{{#with placementFailures as |failures|}}
<h3 class="title is-5" data-test-placement-failure-task-group>
{{taskGroup.name}}
{{placementFailures.name}}
<span class="badge is-light" data-test-placement-failure-coalesced-failures>{{inc failures.coalescedFailures}} unplaced</span>
</h3>
<ul class="simple-list">
@ -37,4 +37,3 @@
</ul>
{{/with}}
{{/if}}

View file

@ -4,16 +4,22 @@
</button>
{{else if isPendingConfirmation}}
<span data-test-confirmation-message class="confirmation-text">{{confirmationMessage}}</span>
<button data-test-cancel-button type="button" class="button is-dark is-outlined is-small" onclick={{action (queue
(action "setToIdle")
(action onCancel)
)}}>
<button
data-test-cancel-button
type="button"
class="button is-dark is-outlined is-small"
disabled={{awaitingConfirmation}}
onclick={{action (queue
(action "setToIdle")
(action onCancel)
)}}>
{{cancelText}}
</button>
<button data-test-confirm-button class="button is-danger is-small" onclick={{action (queue
(action "setToIdle")
(action onConfirm)
)}}>
<button
data-test-confirm-button
class="button is-danger is-small {{if awaitingConfirmation "is-loading"}}"
disabled={{awaitingConfirmation}}
onclick={{action "confirm"}}>
{{confirmText}}
</button>
{{/if}}

View file

@ -2,11 +2,16 @@
{{#if isForbidden}}
{{partial "partials/forbidden-message"}}
{{else}}
{{#if filteredJobs.length}}
<div class="content">
<div>{{search-box data-test-jobs-search searchTerm=(mut searchTerm) placeholder="Search jobs..."}}</div>
<div class="columns">
{{#if filteredJobs.length}}
<div class="column">
{{search-box data-test-jobs-search searchTerm=(mut searchTerm) placeholder="Search jobs..."}}
</div>
{{/if}}
<div class="column is-centered">
{{#link-to "jobs.run" data-test-run-job class="button is-primary is-pulled-right"}}Run Job{{/link-to}}
</div>
{{/if}}
</div>
{{#list-pagination
source=sortedJobs
size=pageSize

View file

@ -1,8 +1,21 @@
{{partial "jobs/job/subnav"}}
<section class="section">
<div class="boxed-section">
<div class="boxed-section-body is-full-bleed">
{{json-viewer data-test-definition-view json=model.definition}}
{{#unless isEditing}}
<div class="boxed-section">
<div class="boxed-section-head">
Job Definition
<button class="button is-light is-compact pull-right" type="button" onclick={{action edit}} data-test-edit-job>Edit</button>
</div>
<div class="boxed-section-body is-full-bleed">
{{json-viewer data-test-definition-view json=definition}}
</div>
</div>
</div>
{{else}}
{{job-editor
job=job
cancelable=true
context="edit"
onCancel=(action onCancel)
onSubmit=(action onSubmit)}}
{{/unless}}
</section>

View file

@ -27,7 +27,7 @@
{{#allocation-status-bar allocationContainer=model.summary class="split-view" as |chart|}}
<ol class="legend">
{{#each chart.data as |datum index|}}
<li class="{{datum.className}} {{if (eq datum.index chart.activeDatum.index) "is-active"}} {{if (eq datum.value 0) "is-empty"}}">
<li class="{{datum.className}} {{if (eq datum.label chart.activeDatum.label) "is-active"}} {{if (eq datum.value 0) "is-empty"}}">
<span class="color-swatch {{if datum.className datum.className (concat "swatch-" index)}}" />
<span class="value">{{datum.value}}</span>
<span class="label">

View file

@ -0,0 +1,6 @@
<section class="section">
{{job-editor
job=model
context="new"
onSubmit=(action onSubmit)}}
</section>

View file

@ -8,7 +8,7 @@
<div class="columns">
<div class="column">
<h3 class="title is-4">Token Storage</h3>
<p>To protect Secret IDs, tokens are stored client-side in <a href="https://developer.mozilla.org/en-US/docs/Web/API/Window/sessionStorage">session storage</a>. Your ACL token is automatically cleared from storage upon closing your browser window. You can also manually clear your token instead.</p>
<p>Tokens are stored client-side in <a href="https://developer.mozilla.org/en-US/docs/Web/API/Window/localStorage">local storage</a>. This will persist your token across sessions. You can manually clear your token here.</p>
</div>
<div class="column is-centered is-minimum">
<button class="button is-info" {{action "clearTokenProperties"}}>Clear Token</button>

View file

@ -0,0 +1,6 @@
// Returns a single string based on the response the adapter received
export default function messageFromAdapterError(error) {
if (error.errors) {
return error.errors.mapBy('detail').join('\n\n');
}
}

View file

@ -0,0 +1,19 @@
import { computed } from '@ember/object';
// An Ember.Computed property that persists set values in localStorage
// and will attempt to get its initial value from localStorage before
// falling back to a default.
//
// ex. showTutorial: localStorageProperty('nomadTutorial', true),
export default function localStorageProperty(localStorageKey, defaultValue) {
return computed({
get() {
const persistedValue = window.localStorage.getItem(localStorageKey);
return persistedValue ? JSON.parse(persistedValue) : defaultValue;
},
set(key, value) {
window.localStorage.setItem(localStorageKey, JSON.stringify(value));
return value;
},
});
}

View file

@ -13,7 +13,6 @@ module.exports = function(defaults) {
paths: ['public/images/icons'],
},
codemirror: {
themes: ['solarized'],
modes: ['javascript'],
},
funnel: {

View file

@ -2,6 +2,8 @@ import Ember from 'ember';
import Response from 'ember-cli-mirage/response';
import { HOSTS } from './common';
import { logFrames, logEncode } from './data/logs';
import { generateDiff } from './factories/job-version';
import { generateTaskGroupFailures } from './factories/evaluation';
const { copy } = Ember;
@ -55,6 +57,49 @@ export default function() {
})
);
this.post('/jobs', function(schema, req) {
const body = JSON.parse(req.requestBody);
if (!body.Job) return new Response(400, {}, 'Job is a required field on the request payload');
return okEmpty();
});
this.post('/jobs/parse', function(schema, req) {
const body = JSON.parse(req.requestBody);
if (!body.JobHCL)
return new Response(400, {}, 'JobHCL is a required field on the request payload');
if (!body.Canonicalize) return new Response(400, {}, 'Expected Canonicalize to be true');
// Parse the name out of the first real line of HCL to match IDs in the new job record
// Regex expectation:
// in: job "job-name" {
// out: job-name
const nameFromHCLBlock = /.+?"(.+?)"/;
const jobName = body.JobHCL.trim()
.split('\n')[0]
.match(nameFromHCLBlock)[1];
const job = server.create('job', { id: jobName });
return new Response(200, {}, this.serialize(job));
});
this.post('/job/:id/plan', function(schema, req) {
const body = JSON.parse(req.requestBody);
if (!body.Job) return new Response(400, {}, 'Job is a required field on the request payload');
if (!body.Diff) return new Response(400, {}, 'Expected Diff to be true');
const FailedTGAllocs = body.Job.Unschedulable && generateFailedTGAllocs(body.Job);
return new Response(
200,
{},
JSON.stringify({ FailedTGAllocs, Diff: generateDiff(req.params.id) })
);
});
this.get(
'/job/:id',
withBlockingSupport(function({ jobs }, { params, queryParams }) {
@ -71,6 +116,14 @@ export default function() {
})
);
this.post('/job/:id', function(schema, req) {
const body = JSON.parse(req.requestBody);
if (!body.Job) return new Response(400, {}, 'Job is a required field on the request payload');
return okEmpty();
});
this.get(
'/job/:id/summary',
withBlockingSupport(function({ jobSummaries }, { params }) {
@ -107,8 +160,7 @@ export default function() {
createAllocations: parent.createAllocations,
});
// Return bogus, since the response is normally just eval information
return new Response(200, {}, '{}');
return okEmpty();
});
this.delete('/job/:id', function(schema, { params }) {
@ -118,6 +170,9 @@ export default function() {
});
this.get('/deployment/:id');
this.post('/deployment/promote/:id', function() {
return new Response(204, {}, '');
});
this.get('/job/:id/evaluations', function({ evaluations }, { params }) {
return this.serialize(evaluations.where({ jobId: params.id }));
@ -276,3 +331,22 @@ function filterKeys(object, ...keys) {
return clone;
}
// An empty response but not a 204 No Content. This is still a valid JSON
// response that represents a payload with no worthwhile data.
function okEmpty() {
return new Response(200, {}, '{}');
}
function generateFailedTGAllocs(job, taskGroups) {
const taskGroupsFromSpec = job.TaskGroups && job.TaskGroups.mapBy('Name');
let tgNames = ['tg-one', 'tg-two'];
if (taskGroupsFromSpec && taskGroupsFromSpec.length) tgNames = taskGroupsFromSpec;
if (taskGroups && taskGroups.length) tgNames = taskGroups;
return tgNames.reduce((hash, tgName) => {
hash[tgName] = generateTaskGroupFailures();
return hash;
}, {});
}

View file

@ -8,6 +8,8 @@ export default Factory.extend({
autoRevert: () => Math.random() > 0.5,
promoted: () => Math.random() > 0.5,
requiresPromotion: false,
requireProgressBy: () => faker.date.past(0.5 / 365, REF_TIME),
desiredTotal: faker.random.number({ min: 1, max: 10 }),

View file

@ -27,6 +27,8 @@ export default Factory.extend({
server.create('deployment-task-group-summary', {
deployment,
name: server.db.taskGroups.find(id).name,
desiredCanaries: 1,
promoted: false,
})
);

View file

@ -72,19 +72,7 @@ export default Factory.extend({
}
const placementFailures = failedTaskGroupNames.reduce((hash, name) => {
hash[name] = {
CoalescedFailures: faker.random.number({ min: 1, max: 20 }),
NodesEvaluated: faker.random.number({ min: 1, max: 100 }),
NodesExhausted: faker.random.number({ min: 1, max: 100 }),
NodesAvailable: Math.random() > 0.7 ? generateNodesAvailable() : null,
ClassFiltered: Math.random() > 0.7 ? generateClassFiltered() : null,
ConstraintFiltered: Math.random() > 0.7 ? generateConstraintFiltered() : null,
ClassExhausted: Math.random() > 0.7 ? generateClassExhausted() : null,
DimensionExhausted: Math.random() > 0.7 ? generateDimensionExhausted() : null,
QuotaExhausted: Math.random() > 0.7 ? generateQuotaExhausted() : null,
Scores: Math.random() > 0.7 ? generateScores() : null,
};
hash[name] = generateTaskGroupFailures();
return hash;
}, {});
@ -111,3 +99,19 @@ function assignJob(evaluation, server) {
job_id: job.id,
});
}
export function generateTaskGroupFailures() {
return {
CoalescedFailures: faker.random.number({ min: 1, max: 20 }),
NodesEvaluated: faker.random.number({ min: 1, max: 100 }),
NodesExhausted: faker.random.number({ min: 1, max: 100 }),
NodesAvailable: Math.random() > 0.7 ? generateNodesAvailable() : null,
ClassFiltered: Math.random() > 0.7 ? generateClassFiltered() : null,
ConstraintFiltered: Math.random() > 0.7 ? generateConstraintFiltered() : null,
ClassExhausted: Math.random() > 0.7 ? generateClassExhausted() : null,
DimensionExhausted: Math.random() > 0.7 ? generateDimensionExhausted() : null,
QuotaExhausted: Math.random() > 0.7 ? generateQuotaExhausted() : null,
Scores: Math.random() > 0.7 ? generateScores() : null,
};
}

View file

@ -6,7 +6,7 @@ export default Factory.extend({
stable: faker.random.boolean,
submitTime: () => faker.date.past(2 / 365, REF_TIME) * 1000000,
diff() {
return generateDiff(this);
return generateDiff(this.jobId);
},
jobId: null,
@ -39,10 +39,10 @@ export default Factory.extend({
},
});
function generateDiff(version) {
export function generateDiff(id) {
return {
Fields: null,
ID: version.jobId,
ID: id,
Objects: null,
TaskGroups: [
{

View file

@ -8,8 +8,12 @@ const JOB_TYPES = ['service', 'batch', 'system'];
const JOB_STATUSES = ['pending', 'running', 'dead'];
export default Factory.extend({
id: i => `job-${i}`,
name: i => `${faker.list.random(...JOB_PREFIXES)()}-${faker.hacker.noun().dasherize()}-${i}`,
id: i =>
`${faker.list.random(...JOB_PREFIXES)()}-${faker.hacker.noun().dasherize()}-${i}`.toLowerCase(),
name() {
return this.id;
},
groupsCount: () => faker.random.number({ min: 1, max: 5 }),

View file

@ -5,6 +5,7 @@ module.exports = {
selectSearch: true,
removeMultipleOption: true,
clearSelected: true,
getCodeMirrorInstance: true,
},
env: {
embertest: true,

View file

@ -29,3 +29,56 @@ test('the job definition page requests the job to display in an unmutated form',
.filter(url => url === jobURL);
assert.ok(jobRequests.length === 2, 'Two requests for the job were made');
});
test('the job definition can be edited', function(assert) {
assert.notOk(Definition.editor.isPresent, 'Editor is not shown on load');
Definition.edit();
andThen(() => {
assert.ok(Definition.editor.isPresent, 'Editor is shown after clicking edit');
assert.notOk(Definition.jsonViewer, 'Editor replaces the JSON viewer');
});
});
test('when in editing mode, the action can be canceled, showing the read-only definition again', function(assert) {
Definition.edit();
andThen(() => {
Definition.editor.cancelEditing();
});
andThen(() => {
assert.ok(Definition.jsonViewer, 'The JSON Viewer is back');
assert.notOk(Definition.editor.isPresent, 'The editor is gone');
});
});
test('when in editing mode, the editor is prepopulated with the job definition', function(assert) {
const requests = server.pretender.handledRequests;
const jobDefinition = requests.findBy('url', `/v1/job/${job.id}`).responseText;
const formattedJobDefinition = JSON.stringify(JSON.parse(jobDefinition), null, 2);
Definition.edit();
andThen(() => {
assert.equal(
Definition.editor.editor.contents,
formattedJobDefinition,
'The editor already has the job definition in it'
);
});
});
test('when changes are submitted, the site redirects to the job overview page', function(assert) {
Definition.edit();
andThen(() => {
Definition.editor.plan();
Definition.editor.run();
});
andThen(() => {
assert.equal(currentURL(), `/jobs/${job.id}`, 'Now on the job overview page');
});
});

View file

@ -0,0 +1,98 @@
import { assign } from '@ember/polyfills';
import { currentURL } from 'ember-native-dom-helpers';
import { test } from 'qunit';
import moduleForAcceptance from 'nomad-ui/tests/helpers/module-for-acceptance';
import JobRun from 'nomad-ui/tests/pages/jobs/run';
const newJobName = 'new-job';
const newJobTaskGroupName = 'redis';
const jsonJob = overrides => {
return JSON.stringify(
assign(
{},
{
Name: newJobName,
Namespace: 'default',
Datacenters: ['dc1'],
Priority: 50,
TaskGroups: [
{
Name: newJobTaskGroupName,
Tasks: [
{
Name: 'redis',
Driver: 'docker',
},
],
},
],
},
overrides
),
null,
2
);
};
moduleForAcceptance('Acceptance | job run', {
beforeEach() {
// Required for placing allocations (a result of creating jobs)
server.create('node');
},
});
test('visiting /jobs/run', function(assert) {
JobRun.visit();
andThen(() => {
assert.equal(currentURL(), '/jobs/run');
});
});
test('when submitting a job, the site redirects to the new job overview page', function(assert) {
const spec = jsonJob();
JobRun.visit();
andThen(() => {
JobRun.editor.editor.fillIn(spec);
JobRun.editor.plan();
});
andThen(() => {
JobRun.editor.run();
});
andThen(() => {
assert.equal(
currentURL(),
`/jobs/${newJobName}`,
`Redirected to the job overview page for ${newJobName}`
);
});
});
test('when submitting a job to a different namespace, the redirect to the job overview page takes namespace into account', function(assert) {
const newNamespace = 'second-namespace';
server.create('namespace', { id: newNamespace });
const spec = jsonJob({ Namespace: newNamespace });
JobRun.visit();
andThen(() => {
JobRun.editor.editor.fillIn(spec);
JobRun.editor.plan();
});
andThen(() => {
JobRun.editor.run();
});
andThen(() => {
assert.equal(
currentURL(),
`/jobs/${newJobName}?namespace=${newNamespace}`,
`Redirected to the job overview page for ${newJobName} and switched the namespace to ${newNamespace}`
);
});
});

View file

@ -69,6 +69,18 @@ test('each job row should link to the corresponding job', function(assert) {
});
});
test('the new job button transitions to the new job page', function(assert) {
JobsList.visit();
andThen(() => {
JobsList.runJob();
});
andThen(() => {
assert.equal(currentURL(), '/jobs/run');
});
});
test('when there are no jobs, there is an empty message', function(assert) {
JobsList.visit();

View file

@ -21,18 +21,18 @@ moduleForAcceptance('Acceptance | tokens', {
},
});
test('the token form sets the token in session storage', function(assert) {
test('the token form sets the token in local storage', function(assert) {
const { secretId } = managementToken;
Tokens.visit();
andThen(() => {
assert.ok(window.sessionStorage.nomadTokenSecret == null, 'No token secret set');
assert.ok(window.localStorage.nomadTokenSecret == null, 'No token secret set');
Tokens.secret(secretId).submit();
andThen(() => {
assert.equal(window.sessionStorage.nomadTokenSecret, secretId, 'Token secret was set');
assert.equal(window.localStorage.nomadTokenSecret, secretId, 'Token secret was set');
});
});
});
@ -91,7 +91,7 @@ test('an error message is shown when authenticating a token fails', function(ass
andThen(() => {
assert.ok(
window.sessionStorage.nomadTokenSecret == null,
window.localStorage.nomadTokenSecret == null,
'Token secret is discarded on failure'
);
assert.ok(Tokens.errorMessage, 'Token error message is shown');

View file

@ -0,0 +1,26 @@
import { registerHelper } from '@ember/test';
const invariant = (truthy, error) => {
if (!truthy) throw new Error(error);
};
export function getCodeMirrorInstance(container) {
return function(selector) {
const cmService = container.lookup('service:code-mirror');
const element = document.querySelector(selector);
invariant(element, `Selector ${selector} matched no elements`);
const cm = cmService.instanceFor(element.id);
invariant(cm, `No registered CodeMirror instance for ${selector}`);
return cm;
};
}
export default function registerCodeMirrorHelpers() {
registerHelper('getCodeMirrorInstance', function(app, selector) {
const helper = getCodeMirrorInstance(app.__container__);
return helper(selector);
});
}

View file

@ -6,10 +6,7 @@ import destroyApp from '../helpers/destroy-app';
export default function(name, options = {}) {
module(name, {
beforeEach() {
// Clear session storage (a side effect of token storage)
window.sessionStorage.clear();
// Also clear local storage (a side effect of namespaces and regions)
// Also clear local storage (a side effect of namespaces, regions, and tokens)
window.localStorage.clear();
this.application = startApp();

View file

@ -3,8 +3,10 @@ import { merge } from '@ember/polyfills';
import Application from '../../app';
import config from '../../config/environment';
import registerPowerSelectHelpers from 'ember-power-select/test-support/helpers';
import registerCodeMirrorHelpers from 'nomad-ui/tests/helpers/codemirror';
registerPowerSelectHelpers();
registerCodeMirrorHelpers();
export default function startApp(attrs) {
let attributes = merge({}, config.APP);

View file

@ -0,0 +1,492 @@
import { getOwner } from '@ember/application';
import { assign } from '@ember/polyfills';
import { run } from '@ember/runloop';
import { test, moduleForComponent } from 'ember-qunit';
import wait from 'ember-test-helpers/wait';
import hbs from 'htmlbars-inline-precompile';
import { create } from 'ember-cli-page-object';
import sinon from 'sinon';
import { startMirage } from 'nomad-ui/initializers/ember-cli-mirage';
import { getCodeMirrorInstance } from 'nomad-ui/tests/helpers/codemirror';
import jobEditor from 'nomad-ui/tests/pages/components/job-editor';
import { initialize as fragmentSerializerInitializer } from 'nomad-ui/initializers/fragment-serializer';
const Editor = create(jobEditor());
moduleForComponent('job-editor', 'Integration | Component | job-editor', {
integration: true,
beforeEach() {
window.localStorage.clear();
fragmentSerializerInitializer(getOwner(this));
// Normally getCodeMirrorInstance is a registered test helper,
// but those registered test helpers only work in acceptance tests.
window._getCodeMirrorInstance = window.getCodeMirrorInstance;
window.getCodeMirrorInstance = getCodeMirrorInstance(getOwner(this));
this.store = getOwner(this).lookup('service:store');
this.server = startMirage();
// Required for placing allocations (a result of creating jobs)
this.server.create('node');
Editor.setContext(this);
},
afterEach() {
this.server.shutdown();
Editor.removeContext();
window.getCodeMirrorInstance = window._getCodeMirrorInstance;
delete window._getCodeMirrorInstance;
},
});
const newJobName = 'new-job';
const newJobTaskGroupName = 'redis';
const jsonJob = overrides => {
return JSON.stringify(
assign(
{},
{
Name: newJobName,
Namespace: 'default',
Datacenters: ['dc1'],
Priority: 50,
TaskGroups: [
{
Name: newJobTaskGroupName,
Tasks: [
{
Name: 'redis',
Driver: 'docker',
},
],
},
],
},
overrides
),
null,
2
);
};
const hclJob = () => `
job "${newJobName}" {
namespace = "default"
datacenters = ["dc1"]
task "${newJobTaskGroupName}" {
driver = "docker"
}
}
`;
const commonTemplate = hbs`
{{job-editor
job=job
context=context
onSubmit=onSubmit}}
`;
const cancelableTemplate = hbs`
{{job-editor
job=job
context=context
cancelable=true
onSubmit=onSubmit
onCancel=onCancel}}
`;
const renderNewJob = (component, job) => () => {
component.setProperties({ job, onSubmit: sinon.spy(), context: 'new' });
component.render(commonTemplate);
return wait();
};
const renderEditJob = (component, job) => () => {
component.setProperties({ job, onSubmit: sinon.spy(), onCancel: sinon.spy(), context: 'edit' });
component.render(cancelableTemplate);
};
const planJob = spec => () => {
Editor.editor.fillIn(spec);
return wait().then(() => {
Editor.plan();
return wait();
});
};
test('the default state is an editor with an explanation popup', function(assert) {
let job;
run(() => {
job = this.store.createRecord('job');
});
return wait()
.then(renderNewJob(this, job))
.then(() => {
assert.ok(Editor.editorHelp.isPresent, 'Editor explanation popup is present');
assert.ok(Editor.editor.isPresent, 'Editor is present');
});
});
test('the explanation popup can be dismissed', function(assert) {
let job;
run(() => {
job = this.store.createRecord('job');
});
return wait()
.then(renderNewJob(this, job))
.then(() => {
Editor.editorHelp.dismiss();
return wait();
})
.then(() => {
assert.notOk(Editor.editorHelp.isPresent, 'Editor explanation popup is gone');
assert.equal(
window.localStorage.nomadMessageJobEditor,
'false',
'Dismissal is persisted in localStorage'
);
});
});
test('the explanation popup is not shown once the dismissal state is set in localStorage', function(assert) {
window.localStorage.nomadMessageJobEditor = 'false';
let job;
run(() => {
job = this.store.createRecord('job');
});
return wait()
.then(renderNewJob(this, job))
.then(() => {
assert.notOk(Editor.editorHelp.isPresent, 'Editor explanation popup is gone');
});
});
test('submitting a json job skips the parse endpoint', function(assert) {
const spec = jsonJob();
let job;
run(() => {
job = this.store.createRecord('job');
});
return wait()
.then(renderNewJob(this, job))
.then(planJob(spec))
.then(() => {
const requests = this.server.pretender.handledRequests.mapBy('url');
assert.notOk(requests.includes('/v1/jobs/parse'), 'JSON job spec is not parsed');
assert.ok(requests.includes(`/v1/job/${newJobName}/plan`), 'JSON job spec is still planned');
});
});
test('submitting an hcl job requires the parse endpoint', function(assert) {
const spec = hclJob();
let job;
run(() => {
job = this.store.createRecord('job');
});
return wait()
.then(renderNewJob(this, job))
.then(planJob(spec))
.then(() => {
const requests = this.server.pretender.handledRequests.mapBy('url');
assert.ok(requests.includes('/v1/jobs/parse'), 'HCL job spec is parsed first');
assert.ok(requests.includes(`/v1/job/${newJobName}/plan`), 'HCL job spec is planned');
assert.ok(
requests.indexOf('/v1/jobs/parse') < requests.indexOf(`/v1/job/${newJobName}/plan`),
'Parse comes before Plan'
);
});
});
test('when a job is successfully parsed and planned, the plan is shown to the user', function(assert) {
const spec = hclJob();
let job;
run(() => {
job = this.store.createRecord('job');
});
return wait()
.then(renderNewJob(this, job))
.then(planJob(spec))
.then(() => {
assert.ok(Editor.planOutput, 'The plan is outputted');
assert.notOk(Editor.editor.isPresent, 'The editor is replaced with the plan output');
assert.ok(Editor.planHelp.isPresent, 'The plan explanation popup is shown');
});
});
test('from the plan screen, the cancel button goes back to the editor with the job still in tact', function(assert) {
const spec = hclJob();
let job;
run(() => {
job = this.store.createRecord('job');
});
return wait()
.then(renderNewJob(this, job))
.then(planJob(spec))
.then(() => {
Editor.cancel();
return wait();
})
.then(() => {
assert.ok(Editor.editor.isPresent, 'The editor is shown again');
assert.equal(
Editor.editor.contents,
spec,
'The spec that was planned is still in the editor'
);
});
});
test('when parse fails, the parse error message is shown', function(assert) {
const spec = hclJob();
const errorMessage = 'Parse Failed!! :o';
let job;
run(() => {
job = this.store.createRecord('job');
});
this.server.pretender.post('/v1/jobs/parse', () => [400, {}, errorMessage]);
return wait()
.then(renderNewJob(this, job))
.then(planJob(spec))
.then(() => {
assert.notOk(Editor.planError.isPresent, 'Plan error is not shown');
assert.notOk(Editor.runError.isPresent, 'Run error is not shown');
assert.ok(Editor.parseError.isPresent, 'Parse error is shown');
assert.equal(
Editor.parseError.message,
errorMessage,
'The error message from the server is shown in the error in the UI'
);
});
});
test('when plan fails, the plan error message is shown', function(assert) {
const spec = hclJob();
const errorMessage = 'Plan Failed!! :o';
let job;
run(() => {
job = this.store.createRecord('job');
});
this.server.pretender.post(`/v1/job/${newJobName}/plan`, () => [400, {}, errorMessage]);
return wait()
.then(renderNewJob(this, job))
.then(planJob(spec))
.then(() => {
assert.notOk(Editor.parseError.isPresent, 'Parse error is not shown');
assert.notOk(Editor.runError.isPresent, 'Run error is not shown');
assert.ok(Editor.planError.isPresent, 'Plan error is shown');
assert.equal(
Editor.planError.message,
errorMessage,
'The error message from the server is shown in the error in the UI'
);
});
});
test('when run fails, the run error message is shown', function(assert) {
const spec = hclJob();
const errorMessage = 'Run Failed!! :o';
let job;
run(() => {
job = this.store.createRecord('job');
});
this.server.pretender.post('/v1/jobs', () => [400, {}, errorMessage]);
return wait()
.then(renderNewJob(this, job))
.then(planJob(spec))
.then(() => {
Editor.run();
return wait();
})
.then(() => {
assert.notOk(Editor.planError.isPresent, 'Plan error is not shown');
assert.notOk(Editor.parseError.isPresent, 'Parse error is not shown');
assert.ok(Editor.runError.isPresent, 'Run error is shown');
assert.equal(
Editor.runError.message,
errorMessage,
'The error message from the server is shown in the error in the UI'
);
});
});
test('when the scheduler dry-run has warnings, the warnings are shown to the user', function(assert) {
const spec = jsonJob({ Unschedulable: true });
let job;
run(() => {
job = this.store.createRecord('job');
});
return wait()
.then(renderNewJob(this, job))
.then(planJob(spec))
.then(() => {
assert.ok(
Editor.dryRunMessage.errored,
'The scheduler dry-run message is in the warning state'
);
assert.notOk(
Editor.dryRunMessage.succeeded,
'The success message is not shown in addition to the warning message'
);
assert.ok(
Editor.dryRunMessage.body.includes(newJobTaskGroupName),
'The scheduler dry-run message includes the warning from send back by the API'
);
});
});
test('when the scheduler dry-run has no warnings, a success message is shown to the user', function(assert) {
const spec = hclJob();
let job;
run(() => {
job = this.store.createRecord('job');
});
return wait()
.then(renderNewJob(this, job))
.then(planJob(spec))
.then(() => {
assert.ok(
Editor.dryRunMessage.succeeded,
'The scheduler dry-run message is in the success state'
);
assert.notOk(
Editor.dryRunMessage.errored,
'The warning message is not shown in addition to the success message'
);
});
});
test('when a job is submitted in the edit context, a POST request is made to the update job endpoint', function(assert) {
const spec = hclJob();
let job;
run(() => {
job = this.store.createRecord('job');
});
return wait()
.then(renderEditJob(this, job))
.then(planJob(spec))
.then(() => {
Editor.run();
})
.then(() => {
const requests = this.server.pretender.handledRequests
.filterBy('method', 'POST')
.mapBy('url');
assert.ok(requests.includes(`/v1/job/${newJobName}`), 'A request was made to job update');
assert.notOk(requests.includes('/v1/jobs'), 'A request was not made to job create');
});
});
test('when a job is submitted in the new context, a POST request is made to the create job endpoint', function(assert) {
const spec = hclJob();
let job;
run(() => {
job = this.store.createRecord('job');
});
return wait()
.then(renderNewJob(this, job))
.then(planJob(spec))
.then(() => {
Editor.run();
})
.then(() => {
const requests = this.server.pretender.handledRequests
.filterBy('method', 'POST')
.mapBy('url');
assert.ok(requests.includes('/v1/jobs'), 'A request was made to job create');
assert.notOk(
requests.includes(`/v1/job/${newJobName}`),
'A request was not made to job update'
);
});
});
test('when a job is successfully submitted, the onSubmit hook is called', function(assert) {
const spec = hclJob();
let job;
run(() => {
job = this.store.createRecord('job');
});
return wait()
.then(renderNewJob(this, job))
.then(planJob(spec))
.then(() => {
Editor.run();
return wait();
})
.then(() => {
assert.ok(
this.get('onSubmit').calledWith(newJobName, 'default'),
'The onSubmit hook was called with the correct arguments'
);
});
});
test('when the job-editor cancelable flag is false, there is no cancel button in the header', function(assert) {
let job;
run(() => {
job = this.store.createRecord('job');
});
return wait()
.then(renderNewJob(this, job))
.then(() => {
assert.notOk(Editor.cancelEditingIsAvailable, 'No way to cancel editing');
});
});
test('when the job-editor cancelable flag is true, there is a cancel button in the header', function(assert) {
let job;
run(() => {
job = this.store.createRecord('job');
});
return wait()
.then(renderEditJob(this, job))
.then(() => {
assert.ok(Editor.cancelEditingIsAvailable, 'Cancel editing button exists');
});
});
test('when the job-editor cancel button is clicked, the onCancel hook is called', function(assert) {
let job;
run(() => {
job = this.store.createRecord('job');
});
return wait()
.then(renderEditJob(this, job))
.then(() => {
Editor.cancelEditing();
})
.then(() => {
assert.ok(this.get('onCancel').calledOnce, 'The onCancel hook was called');
});
});

View file

@ -19,11 +19,31 @@ export function stopJob() {
});
}
export function expectStopError(assert) {
export function startJob() {
click('[data-test-start] [data-test-idle-button]');
return wait().then(() => {
click('[data-test-start] [data-test-confirm-button]');
return wait();
});
}
export function expectStartRequest(assert, server, job) {
const expectedURL = jobURL(job);
const request = server.pretender.handledRequests
.filterBy('method', 'POST')
.find(req => req.url === expectedURL);
const requestPayload = JSON.parse(request.requestBody).Job;
assert.ok(request, 'POST URL was made correctly');
assert.ok(requestPayload.Stop == null, 'The Stop signal is not sent in the POST request');
}
export function expectError(assert, title) {
return () => {
assert.equal(
find('[data-test-job-error-title]').textContent,
'Could Not Stop Job',
title,
'Appropriate error is shown'
);
assert.ok(

View file

@ -4,7 +4,14 @@ import { click, find, 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';
import { jobURL, stopJob, expectStopError, expectDeleteRequest } from './helpers';
import {
jobURL,
stopJob,
startJob,
expectError,
expectDeleteRequest,
expectStartRequest,
} from './helpers';
moduleForComponent('job-page/periodic', 'Integration | Component | job-page/periodic', {
integration: true,
@ -167,5 +174,51 @@ test('Stopping a job without proper permissions shows an error message', functio
return wait();
})
.then(stopJob)
.then(expectStopError(assert));
.then(expectError(assert, 'Could Not Stop Job'));
});
test('Starting a job sends a post request for the job using the current definition', function(assert) {
let job;
const mirageJob = this.server.create('job', 'periodic', {
childrenCount: 0,
createAllocations: false,
status: 'dead',
});
this.store.findAll('job');
return wait()
.then(() => {
job = this.store.peekAll('job').findBy('plainId', mirageJob.id);
this.setProperties(commonProperties(job));
this.render(commonTemplate);
return wait();
})
.then(startJob)
.then(() => expectStartRequest(assert, this.server, job));
});
test('Starting a job without proper permissions shows an error message', function(assert) {
this.server.pretender.post('/v1/job/:id', () => [403, {}, null]);
const mirageJob = this.server.create('job', 'periodic', {
childrenCount: 0,
createAllocations: false,
status: 'dead',
});
this.store.findAll('job');
return wait()
.then(() => {
const job = this.store.peekAll('job').findBy('plainId', mirageJob.id);
this.setProperties(commonProperties(job));
this.render(commonTemplate);
return wait();
})
.then(startJob)
.then(expectError(assert, 'Could Not Start Job'));
});

View file

@ -1,16 +1,19 @@
import { getOwner } from '@ember/application';
import { assign } from '@ember/polyfills';
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 { startMirage } from 'nomad-ui/initializers/ember-cli-mirage';
import { stopJob, expectStopError, expectDeleteRequest } from './helpers';
import { startJob, stopJob, expectError, expectDeleteRequest, expectStartRequest } from './helpers';
import Job from 'nomad-ui/tests/pages/jobs/detail';
import { initialize as fragmentSerializerInitializer } from 'nomad-ui/initializers/fragment-serializer';
moduleForComponent('job-page/service', 'Integration | Component | job-page/service', {
integration: true,
beforeEach() {
Job.setContext(this);
fragmentSerializerInitializer(getOwner(this));
window.localStorage.clear();
this.store = getOwner(this).lookup('service:store');
this.server = startMirage();
@ -88,7 +91,45 @@ test('Stopping a job without proper permissions shows an error message', functio
return wait();
})
.then(stopJob)
.then(expectStopError(assert));
.then(expectError(assert, 'Could Not Stop Job'));
});
test('Starting a job sends a post request for the job using the current definition', function(assert) {
let job;
const mirageJob = makeMirageJob(this.server, { status: 'dead' });
this.store.findAll('job');
return wait()
.then(() => {
job = this.store.peekAll('job').findBy('plainId', mirageJob.id);
this.setProperties(commonProperties(job));
this.render(commonTemplate);
return wait();
})
.then(startJob)
.then(() => expectStartRequest(assert, this.server, job));
});
test('Starting a job without proper permissions shows an error message', function(assert) {
this.server.pretender.post('/v1/job/:id', () => [403, {}, null]);
const mirageJob = makeMirageJob(this.server, { status: 'dead' });
this.store.findAll('job');
return wait()
.then(() => {
const job = this.store.peekAll('job').findBy('plainId', mirageJob.id);
this.setProperties(commonProperties(job));
this.render(commonTemplate);
return wait();
})
.then(startJob)
.then(expectError(assert, 'Could Not Start Job'));
});
test('Recent allocations shows allocations in the job context', function(assert) {
@ -165,3 +206,77 @@ test('Recent allocations shows an empty message when the job has no allocations'
);
});
});
test('Active deployment can be promoted', function(assert) {
let job;
let deployment;
this.server.create('node');
const mirageJob = makeMirageJob(this.server, { activeDeployment: true });
this.store.findAll('job');
return wait()
.then(() => {
job = this.store.peekAll('job').findBy('plainId', mirageJob.id);
deployment = job.get('latestDeployment');
this.setProperties(commonProperties(job));
this.render(commonTemplate);
return wait();
})
.then(() => {
click('[data-test-promote-canary]');
return wait();
})
.then(() => {
const requests = this.server.pretender.handledRequests;
assert.ok(
requests
.filterBy('method', 'POST')
.findBy('url', `/v1/deployment/promote/${deployment.get('id')}`),
'A promote POST request was made'
);
});
});
test('When promoting the active deployment fails, an error is shown', function(assert) {
this.server.pretender.post('/v1/deployment/promote/:id', () => [403, {}, null]);
let job;
this.server.create('node');
const mirageJob = makeMirageJob(this.server, { activeDeployment: true });
this.store.findAll('job');
return wait()
.then(() => {
job = this.store.peekAll('job').findBy('plainId', mirageJob.id);
this.setProperties(commonProperties(job));
this.render(commonTemplate);
return wait();
})
.then(() => {
click('[data-test-promote-canary]');
return wait();
})
.then(() => {
assert.equal(
find('[data-test-job-error-title]').textContent,
'Could Not Promote Deployment',
'Appropriate error is shown'
);
assert.ok(
find('[data-test-job-error-body]').textContent.includes('ACL'),
'The error message mentions ACLs'
);
click('[data-test-job-error-close]');
assert.notOk(find('[data-test-job-error-title]'), 'Error message is dismissable');
return wait();
});
});

View file

@ -108,6 +108,7 @@ function createFixture(obj = {}, name = 'Placement Failure') {
name: name,
placementFailures: assign(
{
name: name,
coalescedFailures: 10,
nodesEvaluated: 0,
nodesAvailable: {

View file

@ -13,6 +13,7 @@ const commonProperties = () => ({
cancelText: 'Cancel Action',
confirmText: 'Confirm Action',
confirmationMessage: 'Are you certain',
awaitingConfirmation: false,
onConfirm: sinon.spy(),
onCancel: sinon.spy(),
});
@ -23,6 +24,7 @@ const commonTemplate = hbs`
cancelText=cancelText
confirmText=confirmText
confirmationMessage=confirmationMessage
awaitingConfirmation=awaitingConfirmation
onConfirm=onConfirm
onCancel=onCancel}}
`;
@ -109,3 +111,27 @@ test('confirming the promptForConfirmation state calls the onConfirm hook and re
});
});
});
test('when awaitingConfirmation is true, the cancel and submit buttons are disabled and the submit button is loading', function(assert) {
const props = commonProperties();
props.awaitingConfirmation = true;
this.setProperties(props);
this.render(commonTemplate);
click('[data-test-idle-button]');
return wait().then(() => {
assert.ok(
find('[data-test-cancel-button]').hasAttribute('disabled'),
'The cancel button is disabled'
);
assert.ok(
find('[data-test-confirm-button]').hasAttribute('disabled'),
'The confirm button is disabled'
);
assert.ok(
find('[data-test-confirm-button]').classList.contains('is-loading'),
'The confirm button is in a loading state'
);
});
});

View file

@ -0,0 +1,11 @@
import { clickable, isPresent, text } from 'ember-cli-page-object';
export default function(selectorBase = 'data-test-error') {
return {
scope: `[${selectorBase}]`,
isPresent: isPresent(),
title: text(`[${selectorBase}-title]`),
message: text(`[${selectorBase}-message]`),
seekHelp: clickable(`[${selectorBase}-message] a`),
};
}

View file

@ -0,0 +1,51 @@
import { clickable, hasClass, isPresent, text } from 'ember-cli-page-object';
import { codeFillable, code } from 'nomad-ui/tests/pages/helpers/codemirror';
import error from 'nomad-ui/tests/pages/components/error';
export default () => ({
scope: '[data-test-job-editor]',
isPresent: isPresent(),
planError: error('data-test-plan-error'),
parseError: error('data-test-parse-error'),
runError: error('data-test-run-error'),
plan: clickable('[data-test-plan]'),
cancel: clickable('[data-test-cancel]'),
run: clickable('[data-test-run]'),
cancelEditing: clickable('[data-test-cancel-editing]'),
cancelEditingIsAvailable: isPresent('[data-test-cancel-editing]'),
planOutput: text('[data-test-plan-output]'),
planHelp: {
isPresent: isPresent('[data-test-plan-help-title]'),
title: text('[data-test-plan-help-title]'),
message: text('[data-test-plan-help-message]'),
dismiss: clickable('[data-test-plan-help-dismiss]'),
},
editorHelp: {
isPresent: isPresent('[data-test-editor-help-title]'),
title: text('[data-test-editor-help-title]'),
message: text('[data-test-editor-help-message]'),
dismiss: clickable('[data-test-editor-help-dismiss]'),
},
editor: {
isPresent: isPresent('[data-test-editor]'),
contents: code('[data-test-editor]'),
fillIn: codeFillable('[data-test-editor]'),
},
dryRunMessage: {
scope: '[data-test-dry-run-message]',
title: text('[data-test-dry-run-title]'),
body: text('[data-test-dry-run-body]'),
errored: hasClass('is-warning'),
succeeded: hasClass('is-primary'),
},
});

View file

@ -0,0 +1,32 @@
// Like fillable, but for the CodeMirror editor
//
// Usage: fillIn: codeFillable('[data-test-editor]')
// Page.fillIn(code);
export function codeFillable(selector) {
return {
isDescriptor: true,
get() {
return function(code) {
const cm = getCodeMirrorInstance(selector);
cm.setValue(code);
return this;
};
},
};
}
// Like text, but for the CodeMirror editor
//
// Usage: content: code('[data-test-editor]')
// Page.code(); // some = [ 'string', 'of', 'code' ]
export function code(selector) {
return {
isDescriptor: true,
get() {
const cm = getCodeMirrorInstance(selector);
return cm.getValue();
},
};
}

View file

@ -1,7 +1,12 @@
import { create, isPresent, visitable } from 'ember-cli-page-object';
import { create, isPresent, visitable, clickable } from 'ember-cli-page-object';
import jobEditor from 'nomad-ui/tests/pages/components/job-editor';
export default create({
visit: visitable('/jobs/:id/definition'),
jsonViewer: isPresent('[data-test-definition-view]'),
editor: jobEditor(),
edit: clickable('[data-test-edit-job]'),
});

View file

@ -16,6 +16,8 @@ export default create({
search: fillable('[data-test-jobs-search] input'),
runJob: clickable('[data-test-run-job]'),
jobs: collection('[data-test-job-row]', {
id: attribute('data-test-job-row'),
name: text('[data-test-job-name]'),

View file

@ -0,0 +1,8 @@
import { create, visitable } from 'ember-cli-page-object';
import jobEditor from 'nomad-ui/tests/pages/components/job-editor';
export default create({
visit: visitable('/jobs/run'),
editor: jobEditor(),
});