plugins/teststeps/cmd/cmd.go (165 lines of code) (raw):

// Copyright (c) Facebook, Inc. and its affiliates. // // This source code is licensed under the MIT license found in the // LICENSE file in the root directory of this source tree. package cmd import ( "bytes" "encoding/json" "errors" "fmt" "os/exec" "path/filepath" "strconv" "syscall" "github.com/facebookincubator/contest/pkg/event" "github.com/facebookincubator/contest/pkg/event/testevent" "github.com/facebookincubator/contest/pkg/target" "github.com/facebookincubator/contest/pkg/test" "github.com/facebookincubator/contest/pkg/xcontext" "github.com/facebookincubator/contest/plugins/teststeps" ) // Name is the name used to look this plugin up. var Name = "Cmd" // event names for this plugin. const ( EventCmdStart = event.Name("CmdStart") EventCmdEnd = event.Name("CmdEnd") EventCmdStdout = event.Name("CmdStdout") EventCmdStderr = event.Name("CmdStderr") ) // Events defines the events that a TestStep is allow to emit var Events = []event.Name{ EventCmdStart, EventCmdEnd, EventCmdStdout, EventCmdStderr, } // eventCmdStartPayload is the payload of an EventStartCmd event, and it // contains the expanded command matching the Cmd struct. type eventCmdStartPayload struct { Path string Args []string Dir string } type eventCmdStdoutPayload struct { Msg string } type eventCmdStderrPayload struct { Msg string } // Cmd is used to run arbitrary commands as test steps. type Cmd struct { executable string args []test.Param dir *test.Param // warning: if you enable emit_stdout and emit_stderr in your plugin // configuration, be aware that the emitted payload is saved to the // ConTest database, and that it might be a very long string. Depending on // the output length, it could be truncated in order to store it. emitStdout, emitStderr bool } // Name returns the plugin name. func (ts Cmd) Name() string { return Name } func emitEvent(ctx xcontext.Context, name event.Name, payload interface{}, tgt *target.Target, ev testevent.Emitter) error { payloadStr, err := json.Marshal(payload) if err != nil { return fmt.Errorf("cannot encode payload for event '%s': %v", name, err) } rm := json.RawMessage(payloadStr) evData := testevent.Data{ EventName: name, Target: tgt, Payload: &rm, } if err := ev.Emit(ctx, evData); err != nil { return fmt.Errorf("cannot emit event EventCmdStart: %v", err) } return nil } // Run executes the cmd step. func (ts *Cmd) Run(ctx xcontext.Context, ch test.TestStepChannels, params test.TestStepParameters, ev testevent.Emitter, resumeState json.RawMessage) (json.RawMessage, error) { log := ctx.Logger() if err := ts.validateAndPopulate(params); err != nil { return nil, err } f := func(ctx xcontext.Context, target *target.Target) error { // expand args var args []string for _, arg := range ts.args { expArg, err := arg.Expand(target) if err != nil { return fmt.Errorf("failed to expand argument '%s': %v", arg, err) } args = append(args, expArg) } cmd := exec.CommandContext(ctx, ts.executable, args...) pwd, err := ts.dir.Expand(target) if err != nil { return fmt.Errorf("failed to expand argument dir '%s': %v", ts.dir, err) } cmd.Dir = pwd var stdout, stderr bytes.Buffer cmd.Stdout, cmd.Stderr = &stdout, &stderr cmd.SysProcAttr = &syscall.SysProcAttr{ // Put the command into a separate session (and group) so signals do not propagate directly to it. Setsid: true, } if cmd.Dir != "" { log.Debugf("Running command '%+v' in directory '%+v'", cmd, cmd.Dir) } else { log.Debugf("Running command '%+v'", cmd) } // Emit EventCmdStart if err := emitEvent(ctx, EventCmdStart, eventCmdStartPayload{Path: cmd.Path, Args: cmd.Args, Dir: cmd.Dir}, target, ev); err != nil { log.Warnf("Failed to emit event: %v", err) } runErr := cmd.Run() if err := emitEvent(ctx, EventCmdEnd, nil, target, ev); err != nil { log.Warnf("Failed to emit event: %v", err) } if ts.emitStdout { log.Infof("Emitting stdout event") if err := emitEvent(ctx, EventCmdStdout, eventCmdStdoutPayload{Msg: stdout.String()}, target, ev); err != nil { log.Warnf("Failed to emit event: %v", err) } } if ts.emitStderr { log.Infof("Emitting stderr event") if err := emitEvent(ctx, EventCmdStderr, eventCmdStderrPayload{Msg: stderr.String()}, target, ev); err != nil { log.Warnf("Failed to emit event: %v", err) } } log.Infof("Command's '%s' with args '%s' stdout '%s', stderr is '%s', run err: '%v'", cmd.Path, cmd.Args, stdout.Bytes(), stderr.Bytes(), runErr) return runErr } return teststeps.ForEachTarget(Name, ctx, ch, f) } func (ts *Cmd) validateAndPopulate(params test.TestStepParameters) error { param := params.GetOne("executable") if param.IsEmpty() { return errors.New("invalid or missing 'executable' parameter, must be exactly one string") } ex := param.String() if filepath.IsAbs(ex) { ts.executable = ex } else { p, err := exec.LookPath(ex) if err != nil { return fmt.Errorf("cannot find '%s' executable in PATH: %v", ex, err) } // the call could still fail later if the file is removed, is not // executable, etc, but at least we do basic checks here. ts.executable = p } ts.args = params.Get("args") ts.dir = params.GetOne("dir") // validate emit_stdout emitStdoutParam := params.GetOne("emit_stdout") if !emitStdoutParam.IsEmpty() { v, err := strconv.ParseBool(emitStdoutParam.String()) if err != nil { return fmt.Errorf("invalid non-boolean `emit_stdout` parameter: %v", err) } ts.emitStdout = v } // validate emit_stderr emitStderrParam := params.GetOne("emit_stderr") if !emitStderrParam.IsEmpty() { v, err := strconv.ParseBool(emitStderrParam.String()) if err != nil { return fmt.Errorf("invalid non-boolean `emit_stderr` parameter: %v", err) } ts.emitStderr = v } return nil } // ValidateParameters validates the parameters associated to the TestStep func (ts *Cmd) ValidateParameters(_ xcontext.Context, params test.TestStepParameters) error { return ts.validateAndPopulate(params) } // New initializes and returns a new Cmd test step. func New() test.TestStep { return &Cmd{} } // Load returns the name, factory and events which are needed to register the step. func Load() (string, test.TestStepFactory, []event.Name) { return Name, New, Events }