baf431f5a883c3c26a37c28eb75c09205f345199
[src/xds/xds-server.git] / lib / apiv1 / exec.go
1 package apiv1
2
3 import (
4         "fmt"
5         "net/http"
6         "os"
7         "regexp"
8         "strconv"
9         "strings"
10         "time"
11
12         "github.com/gin-gonic/gin"
13         common "github.com/iotbzh/xds-common/golib"
14         "github.com/iotbzh/xds-common/golib/eows"
15         "github.com/kr/pty"
16 )
17
18 type (
19         // ExecArgs JSON parameters of /exec command
20         ExecArgs struct {
21                 ID              string   `json:"id" binding:"required"`
22                 SdkID           string   `json:"sdkID"` // sdk ID to use for setting env
23                 CmdID           string   `json:"cmdID"` // command unique ID
24                 Cmd             string   `json:"cmd" binding:"required"`
25                 Args            []string `json:"args"`
26                 Env             []string `json:"env"`
27                 RPath           string   `json:"rpath"`           // relative path into project
28                 TTY             bool     `json:"tty"`             // Use a tty, specific to gdb --tty option
29                 TTYGdbserverFix bool     `json:"ttyGdbserverFix"` // Set to true to activate gdbserver workaround about inferior output
30                 ExitImmediate   bool     `json:"exitImmediate"`   // when true, exit event sent immediately when command exited (IOW, don't wait file synchronization)
31                 CmdTimeout      int      `json:"timeout"`         // command completion timeout in Second
32         }
33
34         // ExecRes JSON result of /exec command
35         ExecRes struct {
36                 Status string `json:"status"` // status OK
37                 CmdID  string `json:"cmdID"`  // command unique ID
38         }
39
40         // ExecSigRes JSON result of /signal command
41         ExecSigRes struct {
42                 Status string `json:"status"` // status OK
43                 CmdID  string `json:"cmdID"`  // command unique ID
44         }
45
46         // ExecInMsg Message used to received input characters (stdin)
47         ExecInMsg struct {
48                 CmdID     string `json:"cmdID"`
49                 Timestamp string `json:"timestamp"`
50                 Stdin     string `json:"stdin"`
51         }
52
53         // ExecOutMsg Message used to send output characters (stdout+stderr)
54         ExecOutMsg struct {
55                 CmdID     string `json:"cmdID"`
56                 Timestamp string `json:"timestamp"`
57                 Stdout    string `json:"stdout"`
58                 Stderr    string `json:"stderr"`
59         }
60
61         // ExecExitMsg Message sent when executed command exited
62         ExecExitMsg struct {
63                 CmdID     string `json:"cmdID"`
64                 Timestamp string `json:"timestamp"`
65                 Code      int    `json:"code"`
66                 Error     error  `json:"error"`
67         }
68
69         // ExecSignalArgs JSON parameters of /exec/signal command
70         ExecSignalArgs struct {
71                 CmdID  string `json:"cmdID" binding:"required"`  // command id
72                 Signal string `json:"signal" binding:"required"` // signal number
73         }
74 )
75
76 const (
77         // ExecInEvent Event send in WS when characters are sent (stdin)
78         ExecInEvent = "exec:input"
79
80         // ExecOutEvent Event send in WS when characters are received (stdout or stderr)
81         ExecOutEvent = "exec:output"
82
83         // ExecExitEvent Event send in WS when program exited
84         ExecExitEvent = "exec:exit"
85
86         // ExecInferiorInEvent Event send in WS when characters are sent to an inferior (used by gdb inferior/tty)
87         ExecInferiorInEvent = "exec:inferior-input"
88
89         // ExecInferiorOutEvent Event send in WS when characters are received by an inferior
90         ExecInferiorOutEvent = "exec:inferior-output"
91 )
92
93 var execCommandID = 1
94
95 // ExecCmd executes remotely a command
96 func (s *APIService) execCmd(c *gin.Context) {
97         var gdbPty, gdbTty *os.File
98         var err error
99         var args ExecArgs
100         if c.BindJSON(&args) != nil {
101                 common.APIError(c, "Invalid arguments")
102                 return
103         }
104
105         // TODO: add permission ?
106
107         // Retrieve session info
108         sess := s.sessions.Get(c)
109         if sess == nil {
110                 common.APIError(c, "Unknown sessions")
111                 return
112         }
113         sop := sess.IOSocket
114         if sop == nil {
115                 common.APIError(c, "Websocket not established")
116                 return
117         }
118
119         // Allow to pass id in url (/exec/:id) or as JSON argument
120         idArg := c.Param("id")
121         if idArg == "" {
122                 idArg = args.ID
123         }
124         if idArg == "" {
125                 common.APIError(c, "Invalid id")
126                 return
127         }
128         id, err := s.mfolders.ResolveID(idArg)
129         if err != nil {
130                 common.APIError(c, err.Error())
131                 return
132         }
133         f := s.mfolders.Get(id)
134         if f == nil {
135                 common.APIError(c, "Unknown id")
136                 return
137         }
138         fld := *f
139         prj := fld.GetConfig()
140
141         // Build command line
142         cmd := []string{}
143         // Setup env var regarding Sdk ID (used for example to setup cross toolchain)
144         if envCmd := s.sdks.GetEnvCmd(args.SdkID, prj.DefaultSdk); len(envCmd) > 0 {
145                 cmd = append(cmd, envCmd...)
146                 cmd = append(cmd, "&&")
147         } else {
148                 // It's an error if no envcmd found while a sdkid has been provided
149                 if args.SdkID != "" {
150                         common.APIError(c, "Unknown sdkid")
151                         return
152                 }
153         }
154
155         cmd = append(cmd, "cd", "\""+fld.GetFullPath(args.RPath)+"\"")
156         // FIXME - add 'exec' prevents to use syntax:
157         //       xds-exec -l debug -c xds-config.env -- "cd build && cmake .."
158         //  but exec is mandatory to allow to pass correctly signals
159         //  As workaround, exec is set for now on client side (eg. in xds-gdb)
160         //cmd = append(cmd, "&&", "exec", args.Cmd)
161         cmd = append(cmd, "&&", args.Cmd)
162
163         // Process command arguments
164         cmdArgs := make([]string, len(args.Args)+1)
165
166         // Copy and Translate path from client to server
167         for _, aa := range args.Args {
168                 if strings.Contains(aa, prj.ClientPath) {
169                         cmdArgs = append(cmdArgs, fld.ConvPathCli2Svr(aa))
170                 } else {
171                         cmdArgs = append(cmdArgs, aa)
172                 }
173         }
174
175         // Allocate pts if tty if used
176         if args.TTY {
177                 gdbPty, gdbTty, err = pty.Open()
178                 if err != nil {
179                         common.APIError(c, err.Error())
180                         return
181                 }
182
183                 s.log.Debugf("Client command tty: %v %v\n", gdbTty.Name(), gdbTty.Name())
184                 cmdArgs = append(cmdArgs, "--tty="+gdbTty.Name())
185         }
186
187         // Unique ID for each commands
188         if args.CmdID == "" {
189                 args.CmdID = s.cfg.ServerUID[:18] + "_" + strconv.Itoa(execCommandID)
190                 execCommandID++
191         }
192
193         // Create new execution over WS context
194         execWS := eows.New(strings.Join(cmd, " "), cmdArgs, sop, sess.ID, args.CmdID)
195         execWS.Log = s.log
196
197         // Append client project dir to environment
198         execWS.Env = append(args.Env, "CLIENT_PROJECT_DIR="+prj.ClientPath)
199
200         // Set command execution timeout
201         if args.CmdTimeout == 0 {
202                 // 0 : default timeout
203                 // TODO get default timeout from config.json file
204                 execWS.CmdExecTimeout = 24 * 60 * 60 // 1 day
205         } else {
206                 execWS.CmdExecTimeout = args.CmdTimeout
207         }
208
209         // Define callback for input (stdin)
210         execWS.InputEvent = ExecInEvent
211         execWS.InputCB = func(e *eows.ExecOverWS, stdin string) (string, error) {
212                 s.log.Debugf("STDIN <<%v>>", strings.Replace(stdin, "\n", "\\n", -1))
213
214                 // Handle Ctrl-D
215                 if len(stdin) == 1 && stdin == "\x04" {
216                         // Close stdin
217                         errMsg := fmt.Errorf("close stdin: %v", stdin)
218                         return "", errMsg
219                 }
220
221                 // Set correct path
222                 data := e.UserData
223                 prjID := (*data)["ID"].(string)
224                 f := s.mfolders.Get(prjID)
225                 if f == nil {
226                         s.log.Errorf("InputCB: Cannot get folder ID %s", prjID)
227                 } else {
228                         // Translate paths from client to server
229                         stdin = (*f).ConvPathCli2Svr(stdin)
230                 }
231
232                 return stdin, nil
233         }
234
235         // Define callback for output (stdout+stderr)
236         execWS.OutputCB = func(e *eows.ExecOverWS, stdout, stderr string) {
237                 // IO socket can be nil when disconnected
238                 so := s.sessions.IOSocketGet(e.Sid)
239                 if so == nil {
240                         s.log.Infof("%s not emitted: WS closed (sid:%s, msgid:%s)", ExecOutEvent, e.Sid, e.CmdID)
241                         return
242                 }
243
244                 // Retrieve project ID and RootPath
245                 data := e.UserData
246                 prjID := (*data)["ID"].(string)
247                 gdbServerTTY := (*data)["gdbServerTTY"].(string)
248
249                 f := s.mfolders.Get(prjID)
250                 if f == nil {
251                         s.log.Errorf("OutputCB: Cannot get folder ID %s", prjID)
252                 } else {
253                         // Translate paths from server to client
254                         stdout = (*f).ConvPathSvr2Cli(stdout)
255                         stderr = (*f).ConvPathSvr2Cli(stderr)
256                 }
257
258                 s.log.Debugf("%s emitted - WS sid[4:] %s - id:%s - prjID:%s", ExecOutEvent, e.Sid[4:], e.CmdID, prjID)
259                 if stdout != "" {
260                         s.log.Debugf("STDOUT <<%v>>", strings.Replace(stdout, "\n", "\\n", -1))
261                 }
262                 if stderr != "" {
263                         s.log.Debugf("STDERR <<%v>>", strings.Replace(stderr, "\n", "\\n", -1))
264                 }
265
266                 // FIXME replace by .BroadcastTo a room
267                 err := (*so).Emit(ExecOutEvent, ExecOutMsg{
268                         CmdID:     e.CmdID,
269                         Timestamp: time.Now().String(),
270                         Stdout:    stdout,
271                         Stderr:    stderr,
272                 })
273                 if err != nil {
274                         s.log.Errorf("WS Emit : %v", err)
275                 }
276
277                 // XXX - Workaround due to gdbserver bug that doesn't redirect
278                 // inferior output (https://bugs.eclipse.org/bugs/show_bug.cgi?id=437532#c13)
279                 if gdbServerTTY == "workaround" && len(stdout) > 1 && stdout[0] == '&' {
280
281                         // Extract and cleanup string like &"bla bla\n"
282                         re := regexp.MustCompile("&\"(.*)\"")
283                         rer := re.FindAllStringSubmatch(stdout, -1)
284                         out := ""
285                         if rer != nil && len(rer) > 0 {
286                                 for _, o := range rer {
287                                         if len(o) >= 1 {
288                                                 out = strings.Replace(o[1], "\\n", "\n", -1)
289                                                 out = strings.Replace(out, "\\r", "\r", -1)
290                                                 out = strings.Replace(out, "\\t", "\t", -1)
291
292                                                 s.log.Debugf("STDOUT INFERIOR: <<%v>>", out)
293                                                 err := (*so).Emit(ExecInferiorOutEvent, ExecOutMsg{
294                                                         CmdID:     e.CmdID,
295                                                         Timestamp: time.Now().String(),
296                                                         Stdout:    out,
297                                                         Stderr:    "",
298                                                 })
299                                                 if err != nil {
300                                                         s.log.Errorf("WS Emit : %v", err)
301                                                 }
302                                         }
303                                 }
304                         } else {
305                                 s.log.Errorf("INFERIOR out parsing error: stdout=<%v>", stdout)
306                         }
307                 }
308         }
309
310         // Define callback for output
311         execWS.ExitCB = func(e *eows.ExecOverWS, code int, err error) {
312                 s.log.Debugf("Command [Cmd ID %s] exited: code %d, error: %v", e.CmdID, code, err)
313
314                 // Close client tty
315                 defer func() {
316                         if gdbPty != nil {
317                                 gdbPty.Close()
318                         }
319                         if gdbTty != nil {
320                                 gdbTty.Close()
321                         }
322                 }()
323
324                 // IO socket can be nil when disconnected
325                 so := s.sessions.IOSocketGet(e.Sid)
326                 if so == nil {
327                         s.log.Infof("%s not emitted - WS closed (id:%s)", ExecExitEvent, e.CmdID)
328                         return
329                 }
330
331                 // Retrieve project ID and RootPath
332                 data := e.UserData
333                 prjID := (*data)["ID"].(string)
334                 exitImm := (*data)["ExitImmediate"].(bool)
335
336                 // XXX - workaround to be sure that Syncthing detected all changes
337                 if err := s.mfolders.ForceSync(prjID); err != nil {
338                         s.log.Errorf("Error while syncing folder %s: %v", prjID, err)
339                 }
340                 if !exitImm {
341                         // Wait end of file sync
342                         // FIXME pass as argument
343                         tmo := 60
344                         for t := tmo; t > 0; t-- {
345                                 s.log.Debugf("Wait file in-sync for %s (%d/%d)", prjID, t, tmo)
346                                 if sync, err := s.mfolders.IsFolderInSync(prjID); sync || err != nil {
347                                         if err != nil {
348                                                 s.log.Errorf("ERROR IsFolderInSync (%s): %v", prjID, err)
349                                         }
350                                         break
351                                 }
352                                 time.Sleep(time.Second)
353                         }
354                         s.log.Debugf("OK file are synchronized.")
355                 }
356
357                 // FIXME replace by .BroadcastTo a room
358                 errSoEmit := (*so).Emit(ExecExitEvent, ExecExitMsg{
359                         CmdID:     e.CmdID,
360                         Timestamp: time.Now().String(),
361                         Code:      code,
362                         Error:     err,
363                 })
364                 if errSoEmit != nil {
365                         s.log.Errorf("WS Emit : %v", errSoEmit)
366                 }
367         }
368
369         // User data (used within callbacks)
370         data := make(map[string]interface{})
371         data["ID"] = prj.ID
372         data["ExitImmediate"] = args.ExitImmediate
373         if args.TTY && args.TTYGdbserverFix {
374                 data["gdbServerTTY"] = "workaround"
375         } else {
376                 data["gdbServerTTY"] = ""
377         }
378         execWS.UserData = &data
379
380         // Start command execution
381         s.log.Infof("Execute [Cmd ID %s]: %v %v", execWS.CmdID, execWS.Cmd, execWS.Args)
382
383         err = execWS.Start()
384         if err != nil {
385                 common.APIError(c, err.Error())
386                 return
387         }
388
389         c.JSON(http.StatusOK, ExecRes{Status: "OK", CmdID: execWS.CmdID})
390 }
391
392 // ExecCmd executes remotely a command
393 func (s *APIService) execSignalCmd(c *gin.Context) {
394         var args ExecSignalArgs
395
396         if c.BindJSON(&args) != nil {
397                 common.APIError(c, "Invalid arguments")
398                 return
399         }
400
401         s.log.Debugf("Signal %s for command ID %s", args.Signal, args.CmdID)
402
403         e := eows.GetEows(args.CmdID)
404         if e == nil {
405                 common.APIError(c, "unknown cmdID")
406                 return
407         }
408
409         err := e.Signal(args.Signal)
410         if err != nil {
411                 common.APIError(c, err.Error())
412                 return
413         }
414
415         c.JSON(http.StatusOK, ExecSigRes{Status: "OK", CmdID: args.CmdID})
416 }