Merge branch 'master' of https://github.com/hashicorp/nomad
* '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:
commit
3e82ad74f4
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
26
plugins/device/cmd/example/README.md
Normal file
26
plugins/device/cmd/example/README.md
Normal 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.
|
18
plugins/device/cmd/example/cmd/main.go
Normal file
18
plugins/device/cmd/example/cmd/main.go
Normal 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)
|
||||
}
|
384
plugins/device/cmd/example/device.go
Normal file
384
plugins/device/cmd/example/device.go
Normal 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
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
27
plugins/serve.go
Normal 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")
|
||||
}
|
||||
}
|
63
plugins/shared/cmd/launcher/README.md
Normal file
63
plugins/shared/cmd/launcher/README.md
Normal 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
|
||||
```
|
364
plugins/shared/cmd/launcher/command/device.go
Normal file
364
plugins/shared/cmd/launcher/command/device.go
Normal 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)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
39
plugins/shared/cmd/launcher/command/meta.go
Normal file
39
plugins/shared/cmd/launcher/command/meta.go
Normal 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)
|
||||
}
|
41
plugins/shared/cmd/launcher/main.go
Normal file
41
plugins/shared/cmd/launcher/main.go
Normal 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)
|
||||
}
|
170
plugins/shared/hclspec/spec.go
Normal file
170
plugins/shared/hclspec/spec.go
Normal 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,
|
||||
})
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
|
|
29
ui/app/adapters/deployment.js
Normal file
29
ui/app/adapters/deployment.js
Normal 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;
|
||||
}
|
|
@ -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) {
|
||||
|
|
102
ui/app/components/job-editor.js
Normal file
102
ui/app/components/job-editor.js
Normal 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);
|
||||
}
|
||||
},
|
||||
});
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
}),
|
||||
});
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
}),
|
||||
});
|
||||
|
|
10
ui/app/components/placement-failure.js
Normal file
10
ui/app/components/placement-failure.js
Normal 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'),
|
||||
});
|
|
@ -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');
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
@ -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 },
|
||||
});
|
||||
},
|
||||
});
|
||||
|
|
9
ui/app/controllers/jobs/run.js
Normal file
9
ui/app/controllers/jobs/run.js
Normal 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 },
|
||||
});
|
||||
},
|
||||
});
|
|
@ -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);
|
||||
},
|
||||
});
|
||||
|
|
8
ui/app/models/job-plan.js
Normal file
8
ui/app/models/job-plan.js
Normal 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: () => [] }),
|
||||
});
|
|
@ -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'),
|
||||
});
|
||||
|
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
@ -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
26
ui/app/routes/jobs/run.js
Normal 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();
|
||||
}
|
||||
},
|
||||
});
|
12
ui/app/serializers/job-plan.js
Normal file
12
ui/app/serializers/job-plan.js
Normal 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);
|
||||
},
|
||||
});
|
|
@ -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;
|
||||
|
|
|
@ -18,6 +18,6 @@ export default Service.extend({
|
|||
},
|
||||
|
||||
setIndexFor(url, value) {
|
||||
list[url] = value;
|
||||
list[url] = +value;
|
||||
},
|
||||
});
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
.page-layout {
|
||||
height: 100%;
|
||||
min-height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
|
|
|
@ -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';
|
||||
|
|
3
ui/app/styles/utils/layout.scss
Normal file
3
ui/app/styles/utils/layout.scss
Normal file
|
@ -0,0 +1,3 @@
|
|||
.is-associative {
|
||||
margin-top: -0.75em;
|
||||
}
|
|
@ -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 |
|
@ -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}}
|
||||
|
|
|
@ -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>
|
||||
|
|
95
ui/app/templates/components/job-editor.hbs
Normal file
95
ui/app/templates/components/job-editor.hbs
Normal 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}}
|
|
@ -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>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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}}
|
||||
|
||||
|
|
|
@ -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}}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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">
|
||||
|
|
6
ui/app/templates/jobs/run.hbs
Normal file
6
ui/app/templates/jobs/run.hbs
Normal file
|
@ -0,0 +1,6 @@
|
|||
<section class="section">
|
||||
{{job-editor
|
||||
job=model
|
||||
context="new"
|
||||
onSubmit=(action onSubmit)}}
|
||||
</section>
|
|
@ -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>
|
||||
|
|
6
ui/app/utils/message-from-adapter-error.js
Normal file
6
ui/app/utils/message-from-adapter-error.js
Normal 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');
|
||||
}
|
||||
}
|
19
ui/app/utils/properties/local-storage.js
Normal file
19
ui/app/utils/properties/local-storage.js
Normal 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;
|
||||
},
|
||||
});
|
||||
}
|
|
@ -13,7 +13,6 @@ module.exports = function(defaults) {
|
|||
paths: ['public/images/icons'],
|
||||
},
|
||||
codemirror: {
|
||||
themes: ['solarized'],
|
||||
modes: ['javascript'],
|
||||
},
|
||||
funnel: {
|
||||
|
|
|
@ -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;
|
||||
}, {});
|
||||
}
|
||||
|
|
|
@ -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 }),
|
||||
|
|
|
@ -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,
|
||||
})
|
||||
);
|
||||
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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: [
|
||||
{
|
||||
|
|
|
@ -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 }),
|
||||
|
||||
|
|
|
@ -5,6 +5,7 @@ module.exports = {
|
|||
selectSearch: true,
|
||||
removeMultipleOption: true,
|
||||
clearSelected: true,
|
||||
getCodeMirrorInstance: true,
|
||||
},
|
||||
env: {
|
||||
embertest: true,
|
||||
|
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
98
ui/tests/acceptance/job-run-test.js
Normal file
98
ui/tests/acceptance/job-run-test.js
Normal 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}`
|
||||
);
|
||||
});
|
||||
});
|
|
@ -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();
|
||||
|
||||
|
|
|
@ -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');
|
||||
|
|
26
ui/tests/helpers/codemirror.js
Normal file
26
ui/tests/helpers/codemirror.js
Normal 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);
|
||||
});
|
||||
}
|
|
@ -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();
|
||||
|
|
|
@ -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);
|
||||
|
|
492
ui/tests/integration/job-editor-test.js
Normal file
492
ui/tests/integration/job-editor-test.js
Normal 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');
|
||||
});
|
||||
});
|
|
@ -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(
|
||||
|
|
|
@ -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'));
|
||||
});
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -108,6 +108,7 @@ function createFixture(obj = {}, name = 'Placement Failure') {
|
|||
name: name,
|
||||
placementFailures: assign(
|
||||
{
|
||||
name: name,
|
||||
coalescedFailures: 10,
|
||||
nodesEvaluated: 0,
|
||||
nodesAvailable: {
|
||||
|
|
|
@ -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'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
11
ui/tests/pages/components/error.js
Normal file
11
ui/tests/pages/components/error.js
Normal 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`),
|
||||
};
|
||||
}
|
51
ui/tests/pages/components/job-editor.js
Normal file
51
ui/tests/pages/components/job-editor.js
Normal 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'),
|
||||
},
|
||||
});
|
32
ui/tests/pages/helpers/codemirror.js
Normal file
32
ui/tests/pages/helpers/codemirror.js
Normal 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();
|
||||
},
|
||||
};
|
||||
}
|
|
@ -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]'),
|
||||
});
|
||||
|
|
|
@ -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]'),
|
||||
|
|
8
ui/tests/pages/jobs/run.js
Normal file
8
ui/tests/pages/jobs/run.js
Normal 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(),
|
||||
});
|
Loading…
Reference in a new issue