diff --git a/drivers/java/driver.go b/drivers/java/driver.go new file mode 100644 index 000000000..6860fd716 --- /dev/null +++ b/drivers/java/driver.go @@ -0,0 +1,564 @@ +package java + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "syscall" + "time" + + "strconv" + + "github.com/hashicorp/consul-template/signals" + "github.com/hashicorp/go-hclog" + "github.com/hashicorp/go-plugin" + "github.com/hashicorp/nomad/client/driver/executor" + dstructs "github.com/hashicorp/nomad/client/driver/structs" + "github.com/hashicorp/nomad/client/fingerprint" + cstructs "github.com/hashicorp/nomad/client/structs" + "github.com/hashicorp/nomad/drivers/shared/eventer" + "github.com/hashicorp/nomad/plugins/base" + "github.com/hashicorp/nomad/plugins/drivers" + "github.com/hashicorp/nomad/plugins/drivers/utils" + "github.com/hashicorp/nomad/plugins/shared/hclspec" + "github.com/hashicorp/nomad/plugins/shared/loader" + "golang.org/x/net/context" +) + +const ( + // pluginName is the name of the plugin + pluginName = "java" + + // fingerprintPeriod is the interval at which the driver will send fingerprint responses + fingerprintPeriod = 30 * time.Second + + // The key populated in Node Attributes to indicate presence of the Java driver + driverAttr = "driver.java" + driverVersionAttr = "driver.java.version" +) + +var ( + // PluginID is the java plugin metadata registered in the plugin + // catalog. + PluginID = loader.PluginID{ + Name: pluginName, + PluginType: base.PluginTypeDriver, + } + + // PluginConfig is the java driver factory function registered in the + // plugin catalog. + PluginConfig = &loader.InternalPluginConfig{ + Config: map[string]interface{}{}, + Factory: func(l hclog.Logger) interface{} { return NewDriver(l) }, + } + + // pluginInfo is the response returned for the PluginInfo RPC + pluginInfo = &base.PluginInfoResponse{ + Type: base.PluginTypeDriver, + PluginApiVersion: "0.0.1", + PluginVersion: "0.1.0", + Name: pluginName, + } + + // configSpec is the hcl specification returned by the ConfigSchema RPC + configSpec = hclspec.NewObject(map[string]*hclspec.Spec{}) + + // taskConfigSpec is the hcl specification for the driver config section of + // a taskConfig within a job. It is returned in the TaskConfigSchema RPC + taskConfigSpec = hclspec.NewObject(map[string]*hclspec.Spec{ + // It's required for either `class` or `jar_path` to be set, + // but that's not expressable in hclspec. Marking both as optional + // and setting checking explicitly later + "class": hclspec.NewAttr("class", "string", false), + "classpath": hclspec.NewAttr("classpath", "string", false), + "jar_path": hclspec.NewAttr("jar_path", "string", false), + "java_opts": hclspec.NewAttr("java_opts", "list(string)", false), + "args": hclspec.NewAttr("args", "list(string)", false), + }) + + // capabilities is returned by the Capabilities RPC and indicates what + // optional features this driver supports + capabilities = &drivers.Capabilities{ + SendSignals: false, + Exec: false, + FSIsolation: cstructs.FSIsolationNone, + } + + _ drivers.DriverPlugin = (*Driver)(nil) +) + +func init() { + if runtime.GOOS == "linux" { + capabilities.FSIsolation = cstructs.FSIsolationChroot + } +} + +// TaskConfig is the driver configuration of a taskConfig within a job +type TaskConfig struct { + Class string `codec:"class"` + ClassPath string `codec:"classpath"` + JarPath string `codec:"jar_path"` + JvmOpts []string `codec:"java_opts"` + Args []string `codec:"args"` // extra arguments to java executable +} + +// TaskState is the state which is encoded in the handle returned in +// StartTask. This information is needed to rebuild the taskConfig state and handler +// during recovery. +type TaskState struct { + ReattachConfig *utils.ReattachConfig + TaskConfig *drivers.TaskConfig + Pid int + StartedAt time.Time +} + +// Driver is a driver for running images via Java +type Driver struct { + // eventer is used to handle multiplexing of TaskEvents calls such that an + // event can be broadcast to all callers + eventer *eventer.Eventer + + // tasks is the in memory datastore mapping taskIDs to taskHandle + tasks *taskStore + + // ctx is the context for the driver. It is passed to other subsystems to + // coordinate shutdown + ctx context.Context + + // nomadConf is the client agent's configuration + nomadConfig *base.ClientDriverConfig + + // signalShutdown is called when the driver is shutting down and cancels the + // ctx passed to any subsystems + signalShutdown context.CancelFunc + + // logger will log to the plugin output which is usually an 'executor.out' + // file located in the root of the TaskDir + logger hclog.Logger +} + +func NewDriver(logger hclog.Logger) drivers.DriverPlugin { + ctx, cancel := context.WithCancel(context.Background()) + logger = logger.Named(pluginName) + return &Driver{ + eventer: eventer.NewEventer(ctx, logger), + tasks: newTaskStore(), + ctx: ctx, + signalShutdown: cancel, + logger: logger, + } +} + +func (d *Driver) PluginInfo() (*base.PluginInfoResponse, error) { + return pluginInfo, nil +} + +func (d *Driver) ConfigSchema() (*hclspec.Spec, error) { + return configSpec, nil +} + +func (d *Driver) SetConfig(_ []byte, cfg *base.ClientAgentConfig) error { + if cfg != nil { + d.nomadConfig = cfg.Driver + } + return nil +} + +func (d *Driver) TaskConfigSchema() (*hclspec.Spec, error) { + return taskConfigSpec, nil +} + +func (d *Driver) Capabilities() (*drivers.Capabilities, error) { + return capabilities, nil +} + +func (r *Driver) Fingerprint(ctx context.Context) (<-chan *drivers.Fingerprint, error) { + ch := make(chan *drivers.Fingerprint) + go r.handleFingerprint(ctx, ch) + return ch, nil +} + +func (d *Driver) handleFingerprint(ctx context.Context, ch chan *drivers.Fingerprint) { + ticker := time.NewTimer(0) + for { + select { + case <-ctx.Done(): + return + case <-d.ctx.Done(): + return + case <-ticker.C: + ticker.Reset(fingerprintPeriod) + ch <- d.buildFingerprint() + } + } +} + +func (d *Driver) buildFingerprint() *drivers.Fingerprint { + fp := &drivers.Fingerprint{ + Attributes: map[string]string{}, + Health: drivers.HealthStateHealthy, + HealthDescription: "healthy", + } + + if runtime.GOOS == "linux" { + // Only enable if w are root and cgroups are mounted when running on linux system + if syscall.Geteuid() != 0 { + fp.Health = drivers.HealthStateUnhealthy + fp.HealthDescription = "java driver must run as root" + return fp + } + + mount, err := fingerprint.FindCgroupMountpointDir() + if err != nil { + fp.Health = drivers.HealthStateUnhealthy + fp.HealthDescription = "failed to discover cgroup mount point" + d.logger.Warn(fp.HealthDescription, "error", err) + return fp + } + + if mount == "" { + fp.Health = drivers.HealthStateUnhealthy + fp.HealthDescription = "cgroups are unavailable" + return fp + } + } + + version, runtime, vm, err := javaVersionInfo() + if err != nil { + // return no error, as it isn't an error to not find java, it just means we + // can't use it. + fp.Health = drivers.HealthStateUndetected + fp.HealthDescription = "" + return fp + } + + fp.Attributes[driverAttr] = "1" + fp.Attributes[driverVersionAttr] = version + fp.Attributes["driver.java.runtime"] = runtime + fp.Attributes["driver.java.vm"] = vm + + return fp +} + +func (d *Driver) RecoverTask(handle *drivers.TaskHandle) error { + if handle == nil { + return fmt.Errorf("error: handle cannot be nil") + } + + var taskState TaskState + if err := handle.GetDriverState(&taskState); err != nil { + d.logger.Error("failed to decode taskConfig state from handle", "error", err, "task_id", handle.Config.ID) + return fmt.Errorf("failed to decode taskConfig state from handle: %v", err) + } + + plugRC, err := utils.ReattachConfigToGoPlugin(taskState.ReattachConfig) + if err != nil { + d.logger.Error("failed to build ReattachConfig from taskConfig state", "error", err, "task_id", handle.Config.ID) + return fmt.Errorf("failed to build ReattachConfig from taskConfig state: %v", err) + } + + pluginConfig := &plugin.ClientConfig{ + Reattach: plugRC, + } + + execImpl, pluginClient, err := utils.CreateExecutorWithConfig(pluginConfig, os.Stderr) + if err != nil { + d.logger.Error("failed to reattach to executor", "error", err, "task_id", handle.Config.ID) + return fmt.Errorf("failed to reattach to executor: %v", err) + } + + h := &taskHandle{ + exec: execImpl, + pid: taskState.Pid, + pluginClient: pluginClient, + taskConfig: taskState.TaskConfig, + procState: drivers.TaskStateRunning, + startedAt: taskState.StartedAt, + exitResult: &drivers.ExitResult{}, + } + + d.tasks.Set(taskState.TaskConfig.ID, h) + + go h.run() + return nil +} + +func (d *Driver) StartTask(cfg *drivers.TaskConfig) (*drivers.TaskHandle, *cstructs.DriverNetwork, error) { + if _, ok := d.tasks.Get(cfg.ID); ok { + return nil, nil, fmt.Errorf("task with ID %q already started", cfg.ID) + } + + var driverConfig TaskConfig + if err := cfg.DecodeDriverConfig(&driverConfig); err != nil { + return nil, nil, fmt.Errorf("failed to decode driver config: %v", err) + } + + if driverConfig.Class == "" && driverConfig.JarPath == "" { + return nil, nil, fmt.Errorf("jar_path or class must be specified") + } + + absPath, err := GetAbsolutePath("java") + if err != nil { + return nil, nil, fmt.Errorf("failed to find java binary: %s", err) + } + + args := javaCmdArgs(driverConfig) + + d.logger.Info("starting java task", "driver_cfg", hclog.Fmt("%+v", driverConfig), "args", args) + + handle := drivers.NewTaskHandle(pluginName) + handle.Config = cfg + + pluginLogFile := filepath.Join(cfg.TaskDir().Dir, "executor.out") + executorConfig := &dstructs.ExecutorConfig{ + LogFile: pluginLogFile, + LogLevel: "debug", + } + + exec, pluginClient, err := utils.CreateExecutor(os.Stderr, hclog.Debug, d.nomadConfig, executorConfig) + if err != nil { + return nil, nil, fmt.Errorf("failed to create executor: %v", err) + } + + execCmd := &executor.ExecCommand{ + Cmd: absPath, + Args: args, + Env: cfg.EnvList(), + User: cfg.User, + ResourceLimits: true, + Resources: &executor.Resources{ + CPU: int(cfg.Resources.LinuxResources.CPUShares), + MemoryMB: int(drivers.BytesToMB(cfg.Resources.LinuxResources.MemoryLimitBytes)), + DiskMB: cfg.Resources.NomadResources.DiskMB, + }, + TaskDir: cfg.TaskDir().Dir, + StdoutPath: cfg.StdoutPath, + StderrPath: cfg.StderrPath, + } + + ps, err := exec.Launch(execCmd) + if err != nil { + pluginClient.Kill() + return nil, nil, fmt.Errorf("failed to launch command with executor: %v", err) + } + + h := &taskHandle{ + exec: exec, + pid: ps.Pid, + pluginClient: pluginClient, + taskConfig: cfg, + procState: drivers.TaskStateRunning, + startedAt: time.Now().Round(time.Millisecond), + logger: d.logger, + } + + driverState := TaskState{ + ReattachConfig: utils.ReattachConfigFromGoPlugin(pluginClient.ReattachConfig()), + Pid: ps.Pid, + TaskConfig: cfg, + StartedAt: h.startedAt, + } + + if err := handle.SetDriverState(&driverState); err != nil { + d.logger.Error("failed to start task, error setting driver state", "error", err) + exec.Shutdown("", 0) + pluginClient.Kill() + return nil, nil, fmt.Errorf("failed to set driver state: %v", err) + } + + d.tasks.Set(cfg.ID, h) + go h.run() + return handle, nil, nil +} + +func javaCmdArgs(driverConfig TaskConfig) []string { + args := []string{} + // Look for jvm options + if len(driverConfig.JvmOpts) != 0 { + args = append(args, driverConfig.JvmOpts...) + } + + // Add the classpath + if driverConfig.ClassPath != "" { + args = append(args, "-cp", driverConfig.ClassPath) + } + + // Add the jar + if driverConfig.JarPath != "" { + args = append(args, "-jar", driverConfig.JarPath) + } + + // Add the class + if driverConfig.Class != "" { + args = append(args, driverConfig.Class) + } + + // Add any args + if len(driverConfig.Args) != 0 { + args = append(args, driverConfig.Args...) + } + + return args +} + +func (d *Driver) WaitTask(ctx context.Context, taskID string) (<-chan *drivers.ExitResult, error) { + handle, ok := d.tasks.Get(taskID) + if !ok { + return nil, drivers.ErrTaskNotFound + } + + ch := make(chan *drivers.ExitResult) + go d.handleWait(ctx, handle, ch) + + return ch, nil +} + +func (d *Driver) handleWait(ctx context.Context, handle *taskHandle, ch chan *drivers.ExitResult) { + defer close(ch) + var result *drivers.ExitResult + ps, err := handle.exec.Wait() + if err != nil { + result = &drivers.ExitResult{ + Err: fmt.Errorf("executor: error waiting on process: %v", err), + } + } else { + result = &drivers.ExitResult{ + ExitCode: ps.ExitCode, + Signal: ps.Signal, + } + } + + select { + case <-ctx.Done(): + return + case <-d.ctx.Done(): + return + case ch <- result: + } +} + +func (d *Driver) StopTask(taskID string, timeout time.Duration, signal string) error { + handle, ok := d.tasks.Get(taskID) + if !ok { + return drivers.ErrTaskNotFound + } + + if err := handle.exec.Shutdown(signal, timeout); err != nil { + if handle.pluginClient.Exited() { + return nil + } + return fmt.Errorf("executor Shutdown failed: %v", err) + } + + return nil +} + +func (d *Driver) DestroyTask(taskID string, force bool) error { + handle, ok := d.tasks.Get(taskID) + if !ok { + return drivers.ErrTaskNotFound + } + + if handle.IsRunning() && !force { + return fmt.Errorf("cannot destroy running task") + } + + if !handle.pluginClient.Exited() { + if handle.IsRunning() { + if err := handle.exec.Shutdown("", 0); err != nil { + handle.logger.Error("destroying executor failed", "err", err) + } + } + + handle.pluginClient.Kill() + } + + d.tasks.Delete(taskID) + return nil +} + +func (d *Driver) InspectTask(taskID string) (*drivers.TaskStatus, error) { + handle, ok := d.tasks.Get(taskID) + if !ok { + return nil, drivers.ErrTaskNotFound + } + + handle.stateLock.RLock() + defer handle.stateLock.RUnlock() + + status := &drivers.TaskStatus{ + ID: handle.taskConfig.ID, + Name: handle.taskConfig.Name, + State: handle.procState, + StartedAt: handle.startedAt, + CompletedAt: handle.completedAt, + ExitResult: handle.exitResult, + DriverAttributes: map[string]string{ + "pid": strconv.Itoa(handle.pid), + }, + } + + return status, nil +} + +func (d *Driver) TaskStats(taskID string) (*cstructs.TaskResourceUsage, error) { + handle, ok := d.tasks.Get(taskID) + if !ok { + return nil, drivers.ErrTaskNotFound + } + + return handle.exec.Stats() +} + +func (d *Driver) TaskEvents(ctx context.Context) (<-chan *drivers.TaskEvent, error) { + return d.eventer.TaskEvents(ctx) +} + +func (d *Driver) SignalTask(taskID string, signal string) error { + handle, ok := d.tasks.Get(taskID) + if !ok { + return drivers.ErrTaskNotFound + } + + sig := os.Interrupt + if s, ok := signals.SignalLookup[signal]; ok { + d.logger.Warn("signal to send to task unknown, using SIGINT", "signal", signal, "task_id", handle.taskConfig.ID) + sig = s + } + return handle.exec.Signal(sig) +} + +func (d *Driver) ExecTask(taskID string, cmd []string, timeout time.Duration) (*drivers.ExecTaskResult, error) { + if len(cmd) == 0 { + return nil, fmt.Errorf("error cmd must have at least one value") + } + handle, ok := d.tasks.Get(taskID) + if !ok { + return nil, drivers.ErrTaskNotFound + } + + out, exitCode, err := handle.exec.Exec(time.Now().Add(timeout), cmd[0], cmd[1:]) + if err != nil { + return nil, err + } + + return &drivers.ExecTaskResult{ + Stdout: out, + ExitResult: &drivers.ExitResult{ + ExitCode: exitCode, + }, + }, nil +} + +// GetAbsolutePath returns the absolute path of the passed binary by resolving +// it in the path and following symlinks. +func GetAbsolutePath(bin string) (string, error) { + lp, err := exec.LookPath(bin) + if err != nil { + return "", fmt.Errorf("failed to resolve path to %q executable: %v", bin, err) + } + + return filepath.EvalSymlinks(lp) +} diff --git a/drivers/java/handle.go b/drivers/java/handle.go new file mode 100644 index 000000000..fa991d00d --- /dev/null +++ b/drivers/java/handle.go @@ -0,0 +1,61 @@ +package java + +import ( + "sync" + "time" + + "github.com/hashicorp/go-hclog" + "github.com/hashicorp/go-plugin" + "github.com/hashicorp/nomad/client/driver/executor" + "github.com/hashicorp/nomad/plugins/drivers" +) + +type taskHandle struct { + exec executor.Executor + pid int + pluginClient *plugin.Client + logger hclog.Logger + monitorPath string + + // stateLock syncs access to all fields below + stateLock sync.RWMutex + + taskConfig *drivers.TaskConfig + procState drivers.TaskState + startedAt time.Time + completedAt time.Time + exitResult *drivers.ExitResult +} + +func (h *taskHandle) IsRunning() bool { + h.stateLock.RLock() + defer h.stateLock.RUnlock() + + return h.procState == drivers.TaskStateRunning +} + +func (h *taskHandle) run() { + + h.stateLock.Lock() + if h.exitResult == nil { + h.exitResult = &drivers.ExitResult{} + } + h.stateLock.Unlock() + + ps, err := h.exec.Wait() + h.stateLock.Lock() + defer h.stateLock.Unlock() + + if err != nil { + h.exitResult.Err = err + h.procState = drivers.TaskStateUnknown + h.completedAt = time.Now() + return + } + h.procState = drivers.TaskStateExited + h.exitResult.ExitCode = ps.ExitCode + h.exitResult.Signal = ps.Signal + h.completedAt = ps.Time + + // TODO: detect if the taskConfig OOMed +} diff --git a/drivers/java/state.go b/drivers/java/state.go new file mode 100644 index 000000000..93b8a7bf4 --- /dev/null +++ b/drivers/java/state.go @@ -0,0 +1,33 @@ +package java + +import ( + "sync" +) + +type taskStore struct { + store map[string]*taskHandle + lock sync.RWMutex +} + +func newTaskStore() *taskStore { + return &taskStore{store: map[string]*taskHandle{}} +} + +func (ts *taskStore) Set(id string, handle *taskHandle) { + ts.lock.Lock() + defer ts.lock.Unlock() + ts.store[id] = handle +} + +func (ts *taskStore) Get(id string) (*taskHandle, bool) { + ts.lock.RLock() + defer ts.lock.RUnlock() + t, ok := ts.store[id] + return t, ok +} + +func (ts *taskStore) Delete(id string) { + ts.lock.Lock() + defer ts.lock.Unlock() + delete(ts.store, id) +} diff --git a/drivers/java/utils.go b/drivers/java/utils.go new file mode 100644 index 000000000..3bf9c16f8 --- /dev/null +++ b/drivers/java/utils.go @@ -0,0 +1,44 @@ +package java + +import ( + "bytes" + "fmt" + "os/exec" + "regexp" + "strings" +) + +var javaVersionCommand = []string{"java", "-version"} + +func javaVersionInfo() (version, runtime, vm string, err error) { + var out bytes.Buffer + + cmd := exec.Command(javaVersionCommand[0], javaVersionCommand[1:]...) + cmd.Stdout = &out + cmd.Stderr = &out + err = cmd.Run() + if err != nil { + return + } + + version, runtime, vm, err = parseJavaVersionOutput(out.String()) + return +} + +func parseJavaVersionOutput(infoString string) (version, runtime, vm string, err error) { + infoString = strings.TrimSpace(infoString) + + lines := strings.Split(infoString, "\n") + if len(lines) != 3 { + return "", "", "", fmt.Errorf("unexpected java version info output, expected 3 lines but got: %v", infoString) + } + + versionString := strings.TrimSpace(lines[0]) + + re := regexp.MustCompile(`version "([^"]*)"`) + if match := re.FindStringSubmatch(lines[0]); len(match) == 2 { + versionString = match[1] + } + + return versionString, strings.TrimSpace(lines[1]), strings.TrimSpace(lines[2]), nil +} diff --git a/drivers/java/utils_test.go b/drivers/java/utils_test.go new file mode 100644 index 000000000..4c4165ecc --- /dev/null +++ b/drivers/java/utils_test.go @@ -0,0 +1,84 @@ +package java + +import ( + "fmt" + "runtime" + "testing" + + "github.com/stretchr/testify/require" +) + +const oracleJDKOutput = `java version "1.7.0_80" +Java(TM) SE Runtime Environment (build 1.7.0_80-b15) +Java HotSpot(TM) 64-Bit Server VM (build 24.80-b11, mixed mode) +` + +func TestDriver_parseJavaVersionOutput(t *testing.T) { + cases := []struct { + name string + output string + version string + runtime string + vm string + }{ + { + "OracleJDK", + oracleJDKOutput, + "1.7.0_80", + "Java(TM) SE Runtime Environment (build 1.7.0_80-b15)", + "Java HotSpot(TM) 64-Bit Server VM (build 24.80-b11, mixed mode)", + }, + { + "OpenJDK", + `openjdk version "11.0.1" 2018-10-16 + OpenJDK Runtime Environment 18.9 (build 11.0.1+13) + OpenJDK 64-Bit Server VM 18.9 (build 11.0.1+13, mixed mode)`, + "11.0.1", + "OpenJDK Runtime Environment 18.9 (build 11.0.1+13)", + "OpenJDK 64-Bit Server VM 18.9 (build 11.0.1+13, mixed mode)", + }, + { + "IcedTea", + `java version "1.6.0_36" + OpenJDK Runtime Environment (IcedTea6 1.13.8) (6b36-1.13.8-0ubuntu1~12.04) + OpenJDK 64-Bit Server VM (build 23.25-b01, mixed mode)`, + "1.6.0_36", + "OpenJDK Runtime Environment (IcedTea6 1.13.8) (6b36-1.13.8-0ubuntu1~12.04)", + "OpenJDK 64-Bit Server VM (build 23.25-b01, mixed mode)", + }, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + version, runtime, vm, err := parseJavaVersionOutput(c.output) + require.NoError(t, err) + + require.Equal(t, c.version, version) + require.Equal(t, c.runtime, runtime) + require.Equal(t, c.vm, vm) + }) + } +} + +func TestDriver_javaVersionInfo(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("test requires bash to run") + } + + initCmd := javaVersionCommand + defer func() { + javaVersionCommand = initCmd + }() + + javaVersionCommand = []string{ + "/bin/sh", "-c", + fmt.Sprintf("printf '%%s\n' '%s' >/dev/stderr", oracleJDKOutput), + } + + version, runtime, vm, err := javaVersionInfo() + require.NoError(t, err) + require.Equal(t, "1.7.0_80", version) + require.Equal(t, "Java(TM) SE Runtime Environment (build 1.7.0_80-b15)", runtime) + require.Equal(t, "Java HotSpot(TM) 64-Bit Server VM (build 24.80-b11, mixed mode)", vm) + +} diff --git a/drivers/rawexec/driver.go b/drivers/rawexec/driver.go index fefd3190d..4363e9a9a 100644 --- a/drivers/rawexec/driver.go +++ b/drivers/rawexec/driver.go @@ -479,19 +479,14 @@ func (d *Driver) SignalTask(taskID string, signal string) error { func (d *Driver) ExecTask(taskID string, cmd []string, timeout time.Duration) (*drivers.ExecTaskResult, error) { if len(cmd) == 0 { - return nil, fmt.Errorf("error cmd must have atleast one value") + return nil, fmt.Errorf("error cmd must have at least one value") } handle, ok := d.tasks.Get(taskID) if !ok { return nil, drivers.ErrTaskNotFound } - args := []string{} - if len(cmd) > 1 { - args = cmd[1:] - } - - out, exitCode, err := handle.exec.Exec(time.Now().Add(timeout), cmd[0], args) + out, exitCode, err := handle.exec.Exec(time.Now().Add(timeout), cmd[0], cmd[1:]) if err != nil { return nil, err }