executor.go 9.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387
  1. package executor
  2. import (
  3. acl "github.com/OliveTin/OliveTin/internal/acl"
  4. config "github.com/OliveTin/OliveTin/internal/config"
  5. sv "github.com/OliveTin/OliveTin/internal/stringvariables"
  6. "github.com/google/uuid"
  7. log "github.com/sirupsen/logrus"
  8. "github.com/prometheus/client_golang/prometheus"
  9. "github.com/prometheus/client_golang/prometheus/promauto"
  10. "bytes"
  11. "context"
  12. "fmt"
  13. "io"
  14. "os/exec"
  15. "runtime"
  16. "sync"
  17. "time"
  18. )
  19. var (
  20. metricActionsRequested = promauto.NewGauge(prometheus.GaugeOpts{
  21. Name: "olivetin_actions_requested_count",
  22. Help: "The actions requested count",
  23. })
  24. )
  25. // Executor represents a helper class for executing commands. It's main method
  26. // is ExecRequest
  27. type Executor struct {
  28. Logs map[string]*InternalLogEntry
  29. listeners []listener
  30. chainOfCommand []executorStepFunc
  31. }
  32. // ExecutionRequest is a request to execute an action. It's passed to an
  33. // Executor. They're created from the grpcapi.
  34. type ExecutionRequest struct {
  35. ActionTitle string
  36. Action *config.Action
  37. Arguments map[string]string
  38. TrackingID string
  39. Tags []string
  40. Cfg *config.Config
  41. AuthenticatedUser *acl.AuthenticatedUser
  42. EntityPrefix string
  43. logEntry *InternalLogEntry
  44. finalParsedCommand string
  45. executor *Executor
  46. }
  47. // InternalLogEntry objects are created by an Executor, and represent the final
  48. // state of execution (even if the command is not executed). It's designed to be
  49. // easily serializable.
  50. type InternalLogEntry struct {
  51. DatetimeStarted string
  52. DatetimeFinished string
  53. Stdout string
  54. Stderr string
  55. StdoutBuffer io.ReadCloser
  56. StderrBuffer io.ReadCloser
  57. TimedOut bool
  58. Blocked bool
  59. ExitCode int32
  60. Tags []string
  61. ExecutionStarted bool
  62. ExecutionFinished bool
  63. ExecutionTrackingID string
  64. /*
  65. The following 3 properties are obviously on Action normally, but it's useful
  66. that logs are lightweight (so we don't need to have an action associated to
  67. logs, etc. Therefore, we duplicate those values here.
  68. */
  69. ActionTitle string
  70. ActionIcon string
  71. ActionId string
  72. }
  73. type executorStepFunc func(*ExecutionRequest) bool
  74. // DefaultExecutor returns an Executor, with a sensible "chain of command" for
  75. // executing actions.
  76. func DefaultExecutor() *Executor {
  77. e := Executor{}
  78. e.Logs = make(map[string]*InternalLogEntry)
  79. e.chainOfCommand = []executorStepFunc{
  80. stepRequestAction,
  81. stepConcurrencyCheck,
  82. stepACLCheck,
  83. stepParseArgs,
  84. stepLogStart,
  85. stepExec,
  86. stepExecAfter,
  87. stepLogFinish,
  88. stepTrigger,
  89. }
  90. return &e
  91. }
  92. type listener interface {
  93. OnExecutionStarted(actionTitle string)
  94. OnExecutionFinished(logEntry *InternalLogEntry)
  95. }
  96. func (e *Executor) AddListener(m listener) {
  97. e.listeners = append(e.listeners, m)
  98. }
  99. // ExecRequest processes an ExecutionRequest
  100. func (e *Executor) ExecRequest(req *ExecutionRequest) (*sync.WaitGroup, string) {
  101. req.executor = e
  102. // req.UUID is now set by the client, so that they can track the request
  103. // from start to finish. This means that a malicious client could send
  104. // duplicate UUIDs (or just random strings), but this is the only way.
  105. req.logEntry = &InternalLogEntry{
  106. DatetimeStarted: time.Now().Format("2006-01-02 15:04:05"),
  107. ExecutionTrackingID: req.TrackingID,
  108. Stdout: "",
  109. Stderr: "",
  110. ExitCode: -1337, // If an Action is not actually executed, this is the default exit code.
  111. ExecutionStarted: false,
  112. ExecutionFinished: false,
  113. ActionId: "",
  114. ActionTitle: "notfound",
  115. ActionIcon: "💩",
  116. }
  117. _, foundLog := e.Logs[req.TrackingID]
  118. if foundLog || req.TrackingID == "" {
  119. req.TrackingID = uuid.NewString()
  120. }
  121. e.Logs[req.TrackingID] = req.logEntry
  122. wg := new(sync.WaitGroup)
  123. wg.Add(1)
  124. go func() {
  125. e.execChain(req)
  126. defer wg.Done()
  127. }()
  128. return wg, req.TrackingID
  129. }
  130. func (e *Executor) execChain(req *ExecutionRequest) {
  131. for _, step := range e.chainOfCommand {
  132. if !step(req) {
  133. break
  134. }
  135. }
  136. req.logEntry.ExecutionFinished = true
  137. // This isn't a step, because we want to notify all listeners, irrespective
  138. // of how many steps were actually executed.
  139. notifyListeners(req)
  140. }
  141. func getConcurrentCount(req *ExecutionRequest) int {
  142. concurrentCount := 0
  143. for _, log := range req.executor.Logs {
  144. if log.ActionId == req.Action.ID && !log.ExecutionFinished {
  145. concurrentCount += 1
  146. }
  147. }
  148. return concurrentCount
  149. }
  150. func stepConcurrencyCheck(req *ExecutionRequest) bool {
  151. concurrentCount := getConcurrentCount(req)
  152. // Note that the current execution is counted int the logs, so when checking we +1
  153. if concurrentCount >= (req.Action.MaxConcurrent + 1) {
  154. msg := fmt.Sprintf("Blocked from executing. This would mean this action is running %d times concurrently, but this action has maxExecutions set to %d.", concurrentCount, req.Action.MaxConcurrent)
  155. log.WithFields(log.Fields{
  156. "actionTitle": req.logEntry.ActionTitle,
  157. }).Warnf(msg)
  158. req.logEntry.Stdout = msg
  159. req.logEntry.Blocked = true
  160. return false
  161. }
  162. return true
  163. }
  164. func stepACLCheck(req *ExecutionRequest) bool {
  165. return acl.IsAllowedExec(req.Cfg, req.AuthenticatedUser, req.Action)
  166. }
  167. func stepParseArgs(req *ExecutionRequest) bool {
  168. var err error
  169. req.finalParsedCommand, err = parseActionArguments(req.Action.Shell, req.Arguments, req.Action, req.logEntry.ActionTitle, req.EntityPrefix)
  170. if err != nil {
  171. req.logEntry.Stdout = err.Error()
  172. log.Warnf(err.Error())
  173. return false
  174. }
  175. return true
  176. }
  177. func stepRequestAction(req *ExecutionRequest) bool {
  178. // The grpc API always tries to find the action by ID, but it may
  179. if req.Action == nil {
  180. log.WithFields(log.Fields{
  181. "actionTitle": req.ActionTitle,
  182. }).Infof("Action finding by title")
  183. req.Action = req.Cfg.FindAction(req.ActionTitle)
  184. if req.Action == nil {
  185. log.WithFields(log.Fields{
  186. "actionTitle": req.ActionTitle,
  187. }).Warnf("Action requested, but not found")
  188. req.logEntry.Stderr = "Action not found: " + req.ActionTitle
  189. return false
  190. }
  191. }
  192. metricActionsRequested.Inc()
  193. req.logEntry.ActionTitle = sv.ReplaceEntityVars(req.EntityPrefix, req.Action.Title)
  194. req.logEntry.ActionIcon = req.Action.Icon
  195. req.logEntry.ActionId = req.Action.ID
  196. log.WithFields(log.Fields{
  197. "actionTitle": req.logEntry.ActionTitle,
  198. }).Infof("Action requested")
  199. return true
  200. }
  201. func stepLogStart(req *ExecutionRequest) bool {
  202. log.WithFields(log.Fields{
  203. "actionTitle": req.logEntry.ActionTitle,
  204. "timeout": req.Action.Timeout,
  205. }).Infof("Action starting")
  206. return true
  207. }
  208. func stepLogFinish(req *ExecutionRequest) bool {
  209. log.WithFields(log.Fields{
  210. "actionTitle": req.logEntry.ActionTitle,
  211. "stdout": req.logEntry.Stdout,
  212. "stderr": req.logEntry.Stderr,
  213. "timedOut": req.logEntry.TimedOut,
  214. "exit": req.logEntry.ExitCode,
  215. }).Infof("Action finished")
  216. return true
  217. }
  218. func notifyListeners(req *ExecutionRequest) {
  219. for _, listener := range req.executor.listeners {
  220. listener.OnExecutionFinished(req.logEntry)
  221. }
  222. }
  223. func wrapCommandInShell(ctx context.Context, finalParsedCommand string) *exec.Cmd {
  224. if runtime.GOOS == "windows" {
  225. return exec.CommandContext(ctx, "cmd", "/C", finalParsedCommand)
  226. }
  227. return exec.CommandContext(ctx, "sh", "-c", finalParsedCommand)
  228. }
  229. func appendErrorToStderr(err error, logEntry *InternalLogEntry) {
  230. if err != nil {
  231. logEntry.Stderr = err.Error() + "\n\n" + logEntry.Stderr
  232. }
  233. }
  234. func stepExec(req *ExecutionRequest) bool {
  235. ctx, cancel := context.WithTimeout(context.Background(), time.Duration(req.Action.Timeout)*time.Second)
  236. defer cancel()
  237. var stdout bytes.Buffer
  238. var stderr bytes.Buffer
  239. cmd := wrapCommandInShell(ctx, req.finalParsedCommand)
  240. cmd.Stdout = &stdout
  241. cmd.Stderr = &stderr
  242. req.logEntry.StdoutBuffer, _ = cmd.StdoutPipe()
  243. req.logEntry.StderrBuffer, _ = cmd.StderrPipe()
  244. req.logEntry.ExecutionStarted = true
  245. runerr := cmd.Start()
  246. waiterr := cmd.Wait()
  247. req.logEntry.ExitCode = int32(cmd.ProcessState.ExitCode())
  248. req.logEntry.Stdout = stdout.String()
  249. req.logEntry.Stderr = stderr.String()
  250. appendErrorToStderr(runerr, req.logEntry)
  251. appendErrorToStderr(waiterr, req.logEntry)
  252. if ctx.Err() == context.DeadlineExceeded {
  253. req.logEntry.TimedOut = true
  254. }
  255. req.logEntry.Tags = req.Tags
  256. req.logEntry.DatetimeFinished = time.Now().Format("2006-01-02 15:04:05")
  257. return true
  258. }
  259. func stepExecAfter(req *ExecutionRequest) bool {
  260. if req.Action.ShellAfterCompleted == "" {
  261. return true
  262. }
  263. ctx, cancel := context.WithTimeout(context.Background(), time.Duration(req.Action.Timeout)*time.Second)
  264. defer cancel()
  265. var stdout bytes.Buffer
  266. var stderr bytes.Buffer
  267. args := map[string]string{
  268. "stdout": req.logEntry.Stdout,
  269. "exitCode": fmt.Sprintf("%v", req.logEntry.ExitCode),
  270. }
  271. finalParsedCommand, _ := parseActionArguments(req.Action.ShellAfterCompleted, args, req.Action, req.logEntry.ActionTitle, req.EntityPrefix)
  272. cmd := wrapCommandInShell(ctx, finalParsedCommand)
  273. cmd.Stdout = &stdout
  274. cmd.Stderr = &stderr
  275. runerr := cmd.Start()
  276. waiterr := cmd.Wait()
  277. req.logEntry.Stdout += "---\n" + stdout.String()
  278. req.logEntry.Stderr += "---\n" + stderr.String()
  279. appendErrorToStderr(runerr, req.logEntry)
  280. appendErrorToStderr(waiterr, req.logEntry)
  281. if ctx.Err() == context.DeadlineExceeded {
  282. req.logEntry.Stderr += "Your shellAfterCommand command timed out."
  283. }
  284. req.logEntry.Stdout += fmt.Sprintf("Your shellAfterCommand exited with code %v", cmd.ProcessState.ExitCode())
  285. return true
  286. }
  287. func stepTrigger(req *ExecutionRequest) bool {
  288. if req.Action.Trigger != "" {
  289. trigger := &ExecutionRequest{
  290. ActionTitle: req.Action.Trigger,
  291. TrackingID: uuid.NewString(),
  292. Tags: []string{"trigger"},
  293. AuthenticatedUser: req.AuthenticatedUser,
  294. Cfg: req.Cfg,
  295. }
  296. req.executor.ExecRequest(trigger)
  297. }
  298. return true
  299. }