From ab1170e65d6d03dd1eb2542b5fc47694d7785e70 Mon Sep 17 00:00:00 2001 From: Sebastien Douheret Date: Mon, 7 Aug 2017 17:22:15 +0200 Subject: [PATCH] Improved /exec to support gdb execution. /exec now supports stdin and stdout/stderr tunneling over an websocket (socketio). This also supports redirection of inferior process output (stdout only) in particular case of gdb command (set gdb --tty option). --- .vscode/settings.json | 6 +- Makefile | 4 + glide.yaml | 3 + lib/apiv1/exec.go | 312 ++++++++++++++++++++++++++++++++++---------------- 4 files changed, 226 insertions(+), 99 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index bb7040e..429cbbe 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -12,7 +12,6 @@ "webapp/dist": true, "webapp/node_modules": true }, - // Words to add to dictionary for a workspace. "cSpell.words": [ "apiv", @@ -40,6 +39,7 @@ "pkill", "sdkid", "CLOUDSYNC", - "xdsagent" + "xdsagent", + "eows" ] -} \ No newline at end of file +} diff --git a/Makefile b/Makefile index d088c5d..236a415 100644 --- a/Makefile +++ b/Makefile @@ -157,6 +157,10 @@ package-all: vendor: tools/glide glide.yaml $(LOCAL_TOOLSDIR)/glide install --strip-vendor +vendor/debug: vendor + (cd vendor/github.com/iotbzh && \ + rm -rf xds-common && ln -s ../../../../xds-common ) + .PHONY: tools/glide tools/glide: @test -f $(LOCAL_TOOLSDIR)/glide || { \ diff --git a/glide.yaml b/glide.yaml index aecb56c..8b1e84c 100644 --- a/glide.yaml +++ b/glide.yaml @@ -24,3 +24,6 @@ import: - package: github.com/iotbzh/xds-common subpackages: - golib/common + - golib/eows +- package: github.com/kr/pty + version: ^1.0.0 diff --git a/lib/apiv1/exec.go b/lib/apiv1/exec.go index ce0241a..eb93af8 100644 --- a/lib/apiv1/exec.go +++ b/lib/apiv1/exec.go @@ -1,61 +1,88 @@ package apiv1 import ( + "fmt" "net/http" + "os" + "regexp" "strconv" "strings" "time" - "fmt" - "github.com/gin-gonic/gin" common "github.com/iotbzh/xds-common/golib" + "github.com/iotbzh/xds-common/golib/eows" + "github.com/kr/pty" ) -// ExecArgs JSON parameters of /exec command -type ExecArgs struct { - ID string `json:"id" binding:"required"` - SdkID string `json:"sdkid"` // sdk ID to use for setting env - Cmd string `json:"cmd" binding:"required"` - Args []string `json:"args"` - Env []string `json:"env"` - RPath string `json:"rpath"` // relative path into project - ExitImmediate bool `json:"exitImmediate"` // when true, exit event sent immediately when command exited (IOW, don't wait file synchronization) - CmdTimeout int `json:"timeout"` // command completion timeout in Second -} +type ( + // ExecArgs JSON parameters of /exec command + ExecArgs struct { + ID string `json:"id" binding:"required"` + SdkID string `json:"sdkid"` // sdk ID to use for setting env + Cmd string `json:"cmd" binding:"required"` + Args []string `json:"args"` + Env []string `json:"env"` + RPath string `json:"rpath"` // relative path into project + TTY bool `json:"tty"` // Use a tty, specific to gdb --tty option + TTYGdbserverFix bool `json:"ttyGdbserverFix"` // Set to true to activate gdbserver workaround about inferior output + ExitImmediate bool `json:"exitImmediate"` // when true, exit event sent immediately when command exited (IOW, don't wait file synchronization) + CmdTimeout int `json:"timeout"` // command completion timeout in Second + } -// ExecOutMsg Message send on each output (stdout+stderr) of executed command -type ExecOutMsg struct { - CmdID string `json:"cmdID"` - Timestamp string `json:"timestamp"` - Stdout string `json:"stdout"` - Stderr string `json:"stderr"` -} + // ExecInMsg Message used to received input characters (stdin) + ExecInMsg struct { + CmdID string `json:"cmdID"` + Timestamp string `json:"timestamp"` + Stdin string `json:"stdin"` + } -// ExecExitMsg Message send when executed command exited -type ExecExitMsg struct { - CmdID string `json:"cmdID"` - Timestamp string `json:"timestamp"` - Code int `json:"code"` - Error error `json:"error"` -} + // ExecOutMsg Message used to send output characters (stdout+stderr) + ExecOutMsg struct { + CmdID string `json:"cmdID"` + Timestamp string `json:"timestamp"` + Stdout string `json:"stdout"` + Stderr string `json:"stderr"` + } -// ExecSignalArgs JSON parameters of /exec/signal command -type ExecSignalArgs struct { - CmdID string `json:"cmdID" binding:"required"` // command id - Signal string `json:"signal" binding:"required"` // signal number -} + // ExecExitMsg Message sent when executed command exited + ExecExitMsg struct { + CmdID string `json:"cmdID"` + Timestamp string `json:"timestamp"` + Code int `json:"code"` + Error error `json:"error"` + } -// ExecOutEvent Event send in WS when characters are received -const ExecOutEvent = "exec:output" + // ExecSignalArgs JSON parameters of /exec/signal command + ExecSignalArgs struct { + CmdID string `json:"cmdID" binding:"required"` // command id + Signal string `json:"signal" binding:"required"` // signal number + } +) + +const ( + // ExecInEvent Event send in WS when characters are sent (stdin) + ExecInEvent = "exec:input" + + // ExecOutEvent Event send in WS when characters are received (stdout or stderr) + ExecOutEvent = "exec:output" -// ExecExitEvent Event send in WS when program exited -const ExecExitEvent = "exec:exit" + // ExecExitEvent Event send in WS when program exited + ExecExitEvent = "exec:exit" + + // ExecInferiorInEvent Event send in WS when characters are sent to an inferior (used by gdb inferior/tty) + ExecInferiorInEvent = "exec:inferior-input" + + // ExecInferiorOutEvent Event send in WS when characters are received by an inferior + ExecInferiorOutEvent = "exec:inferior-output" +) var execCommandID = 1 // ExecCmd executes remotely a command func (s *APIService) execCmd(c *gin.Context) { + var gdbPty, gdbTty *os.File + var err error var args ExecArgs if c.BindJSON(&args) != nil { common.APIError(c, "Invalid arguments") @@ -92,49 +119,112 @@ func (s *APIService) execCmd(c *gin.Context) { return } - execTmo := args.CmdTimeout - if execTmo == -1 { - // -1 : no timeout - execTmo = 365 * 24 * 60 * 60 // 1 year == no timeout - } else if execTmo == 0 { + // Build command line + cmd := []string{} + // Setup env var regarding Sdk ID (used for example to setup cross toolchain) + if envCmd := s.sdks.GetEnvCmd(args.SdkID, prj.DefaultSdk); len(envCmd) > 0 { + cmd = append(cmd, envCmd...) + cmd = append(cmd, "&&") + } else { + // It's an error if no envcmd found while a sdkid has been provided + if args.SdkID != "" { + common.APIError(c, "Unknown sdkid") + return + } + } + + // FIXME - SEB: exec prevents to use syntax: + // xds-exec -l debug -c xds-config.env -- "cd build && cmake .." + cmd = append(cmd, "cd", prj.GetFullPath(args.RPath)) + cmd = append(cmd, "&&", "exec", args.Cmd) + + // Process command arguments + cmdArgs := make([]string, len(args.Args)+1) + copy(cmdArgs, args.Args) + + // Allocate pts if tty if used + if args.TTY { + gdbPty, gdbTty, err = pty.Open() + if err != nil { + common.APIError(c, err.Error()) + return + } + + s.log.Debugf("Client command tty: %v %v\n", gdbTty.Name(), gdbTty.Name()) + cmdArgs = append(cmdArgs, "--tty="+gdbTty.Name()) + } + + // Unique ID for each commands + cmdID := strconv.Itoa(execCommandID) + execCommandID++ + + // Create new execution over WS context + execWS := eows.New(strings.Join(cmd, " "), cmdArgs, sop, sess.ID, cmdID) + execWS.Log = s.log + + // Append client project dir to environment + execWS.Env = append(args.Env, "CLIENT_PROJECT_DIR="+prj.RelativePath) + + // Set command execution timeout + if args.CmdTimeout == 0 { // 0 : default timeout // TODO get default timeout from config.json file - execTmo = 24 * 60 * 60 // 1 day + execWS.CmdExecTimeout = 24 * 60 * 60 // 1 day + } else { + execWS.CmdExecTimeout = args.CmdTimeout } - // Define callback for input - /* SEB TODO - var iCB common.OnInputCB - iCB = func() { + // Define callback for input (stdin) + execWS.InputEvent = ExecInEvent + execWS.InputCB = func(e *eows.ExecOverWS, stdin string) (string, error) { + s.log.Debugf("STDIN <<%v>>", strings.Replace(stdin, "\n", "\\n", -1)) + + // Handle Ctrl-D + if len(stdin) == 1 && stdin == "\x04" { + // Close stdin + errMsg := fmt.Errorf("close stdin: %v", stdin) + return "", errMsg + } + + // Set correct path + data := e.UserData + rootPath := (*data)["RootPath"].(string) + relaPath := (*data)["RelativePath"].(string) + stdin = strings.Replace(stdin, relaPath, rootPath+"/"+relaPath, -1) + return stdin, nil } - */ - // Define callback for output - var oCB common.EmitOutputCB - oCB = func(sid string, id string, stdout, stderr string, data *map[string]interface{}) { + // Define callback for output (stdout+stderr) + execWS.OutputCB = func(e *eows.ExecOverWS, stdout, stderr string) { // IO socket can be nil when disconnected - so := s.sessions.IOSocketGet(sid) + so := s.sessions.IOSocketGet(e.Sid) if so == nil { - s.log.Infof("%s not emitted: WS closed - sid: %s - msg id:%d", ExecOutEvent, sid, id) + s.log.Infof("%s not emitted: WS closed (sid:%s, msgid:%s)", ExecOutEvent, e.Sid, e.CmdID) return } // Retrieve project ID and RootPath + data := e.UserData prjID := (*data)["ID"].(string) prjRootPath := (*data)["RootPath"].(string) + gdbServerTTY := (*data)["gdbServerTTY"].(string) // Cleanup any references to internal rootpath in stdout & stderr stdout = strings.Replace(stdout, prjRootPath, "", -1) stderr = strings.Replace(stderr, prjRootPath, "", -1) - s.log.Debugf("%s emitted - WS sid %s - id:%d - prjID:%s", ExecOutEvent, sid, id, prjID) - - fmt.Printf("SEB SEND out <%v>, err <%v>\n", stdout, stderr) + s.log.Debugf("%s emitted - WS sid[4:] %s - id:%s - prjID:%s", ExecOutEvent, e.Sid[4:], e.CmdID, prjID) + if stdout != "" { + s.log.Debugf("STDOUT <<%v>>", strings.Replace(stdout, "\n", "\\n", -1)) + } + if stderr != "" { + s.log.Debugf("STDERR <<%v>>", strings.Replace(stderr, "\n", "\\n", -1)) + } // FIXME replace by .BroadcastTo a room err := (*so).Emit(ExecOutEvent, ExecOutMsg{ - CmdID: id, + CmdID: e.CmdID, Timestamp: time.Now().String(), Stdout: stdout, Stderr: stderr, @@ -142,20 +232,53 @@ func (s *APIService) execCmd(c *gin.Context) { if err != nil { s.log.Errorf("WS Emit : %v", err) } + + // XXX - Workaround due to gdbserver bug that doesn't redirect + // inferior output (https://bugs.eclipse.org/bugs/show_bug.cgi?id=437532#c13) + if gdbServerTTY == "workaround" && len(stdout) > 1 && stdout[0] == '&' { + + // Extract and cleanup string like &"bla bla\n" + re := regexp.MustCompile("&\"(.*)\"") + rer := re.FindAllStringSubmatch(stdout, -1) + out := "" + if rer != nil && len(rer) > 0 { + for _, o := range rer { + if len(o) >= 1 { + out = strings.Replace(o[1], "\\n", "\n", -1) + out = strings.Replace(out, "\\r", "\r", -1) + out = strings.Replace(out, "\\t", "\t", -1) + + s.log.Debugf("STDOUT INFERIOR: <<%v>>", out) + err := (*so).Emit(ExecInferiorOutEvent, ExecOutMsg{ + CmdID: e.CmdID, + Timestamp: time.Now().String(), + Stdout: out, + Stderr: "", + }) + if err != nil { + s.log.Errorf("WS Emit : %v", err) + } + } + } + } else { + s.log.Errorf("INFERIOR out parsing error: stdout=<%v>", stdout) + } + } } // Define callback for output - eCB := func(sid string, id string, code int, err error, data *map[string]interface{}) { - s.log.Debugf("Command [Cmd ID %d] exited: code %d, error: %v", id, code, err) + execWS.ExitCB = func(e *eows.ExecOverWS, code int, err error) { + s.log.Debugf("Command [Cmd ID %s] exited: code %d, error: %v", e.CmdID, code, err) // IO socket can be nil when disconnected - so := s.sessions.IOSocketGet(sid) + so := s.sessions.IOSocketGet(e.Sid) if so == nil { - s.log.Infof("%s not emitted - WS closed (id:%d", ExecExitEvent, id) + s.log.Infof("%s not emitted - WS closed (id:%s)", ExecExitEvent, e.CmdID) return } // Retrieve project ID and RootPath + data := e.UserData prjID := (*data)["ID"].(string) exitImm := (*data)["ExitImmediate"].(bool) @@ -179,53 +302,43 @@ func (s *APIService) execCmd(c *gin.Context) { } } + // Close client tty + if gdbPty != nil { + gdbPty.Close() + } + if gdbTty != nil { + gdbTty.Close() + } + // FIXME replace by .BroadcastTo a room - e := (*so).Emit(ExecExitEvent, ExecExitMsg{ - CmdID: id, + errSoEmit := (*so).Emit(ExecExitEvent, ExecExitMsg{ + CmdID: e.CmdID, Timestamp: time.Now().String(), Code: code, Error: err, }) - if e != nil { - s.log.Errorf("WS Emit : %v", e) + if errSoEmit != nil { + s.log.Errorf("WS Emit : %v", errSoEmit) } } - cmdID := strconv.Itoa(execCommandID) - execCommandID++ - cmd := []string{} - - // Setup env var regarding Sdk ID (used for example to setup cross toolchain) - if envCmd := s.sdks.GetEnvCmd(args.SdkID, prj.DefaultSdk); len(envCmd) > 0 { - cmd = append(cmd, envCmd...) - cmd = append(cmd, "&&") - } else { - // It's an error if no envcmd found while a sdkid has been provided - if args.SdkID != "" { - common.APIError(c, "Unknown sdkid") - return - } - } - - cmd = append(cmd, "cd", prj.GetFullPath(args.RPath), "&&", args.Cmd) - if len(args.Args) > 0 { - cmd = append(cmd, args.Args...) - } - - // SEB Workaround for stderr issue (order not respected with stdout) - cmd = append(cmd, " 2>&1") - - // Append client project dir to environment - args.Env = append(args.Env, "CLIENT_PROJECT_DIR="+prj.RelativePath) - - s.log.Debugf("Execute [Cmd ID %s]: %v", cmdID, cmd) - + // User data (used within callbacks) data := make(map[string]interface{}) data["ID"] = prj.ID data["RootPath"] = prj.RootPath + data["RelativePath"] = prj.RelativePath data["ExitImmediate"] = args.ExitImmediate + if args.TTY && args.TTYGdbserverFix { + data["gdbServerTTY"] = "workaround" + } else { + data["gdbServerTTY"] = "" + } + execWS.UserData = &data - err := common.ExecPipeWs(cmd, args.Env, sop, sess.ID, cmdID, execTmo, s.log, oCB, eCB, &data) + // Start command execution + s.log.Debugf("Execute [Cmd ID %s]: %v %v", execWS.CmdID, execWS.Cmd, execWS.Args) + + err = execWS.Start() if err != nil { common.APIError(c, err.Error()) return @@ -234,7 +347,7 @@ func (s *APIService) execCmd(c *gin.Context) { c.JSON(http.StatusOK, gin.H{ "status": "OK", - "cmdID": cmdID, + "cmdID": execWS.CmdID, }) } @@ -248,7 +361,14 @@ func (s *APIService) execSignalCmd(c *gin.Context) { } s.log.Debugf("Signal %s for command ID %s", args.Signal, args.CmdID) - err := common.ExecSignal(args.CmdID, args.Signal) + + e := eows.GetEows(args.CmdID) + if e == nil { + common.APIError(c, "unknown cmdID") + return + } + + err := e.Signal(args.Signal) if err != nil { common.APIError(c, err.Error()) return -- 2.16.6