executor.go 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517
  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. "gopkg.in/yaml.v3"
  11. "bytes"
  12. "context"
  13. "fmt"
  14. "io"
  15. "os"
  16. "os/exec"
  17. "path"
  18. "runtime"
  19. "strings"
  20. "sync"
  21. "time"
  22. )
  23. var (
  24. metricActionsRequested = promauto.NewGauge(prometheus.GaugeOpts{
  25. Name: "olivetin_actions_requested_count",
  26. Help: "The actions requested count",
  27. })
  28. )
  29. // Executor represents a helper class for executing commands. It's main method
  30. // is ExecRequest
  31. type Executor struct {
  32. Logs map[string]*InternalLogEntry
  33. LogsByActionId map[string][]*InternalLogEntry
  34. listeners []listener
  35. chainOfCommand []executorStepFunc
  36. }
  37. // ExecutionRequest is a request to execute an action. It's passed to an
  38. // Executor. They're created from the grpcapi.
  39. type ExecutionRequest struct {
  40. ActionTitle string
  41. Action *config.Action
  42. Arguments map[string]string
  43. TrackingID string
  44. Tags []string
  45. Cfg *config.Config
  46. AuthenticatedUser *acl.AuthenticatedUser
  47. EntityPrefix string
  48. logEntry *InternalLogEntry
  49. finalParsedCommand string
  50. executor *Executor
  51. }
  52. // InternalLogEntry objects are created by an Executor, and represent the final
  53. // state of execution (even if the command is not executed). It's designed to be
  54. // easily serializable.
  55. type InternalLogEntry struct {
  56. DatetimeStarted time.Time
  57. DatetimeFinished time.Time
  58. Stdout string
  59. Stderr string
  60. StdoutBuffer io.ReadCloser
  61. StderrBuffer io.ReadCloser
  62. TimedOut bool
  63. Blocked bool
  64. ExitCode int32
  65. Tags []string
  66. ExecutionStarted bool
  67. ExecutionFinished bool
  68. ExecutionTrackingID string
  69. Process *os.Process
  70. /*
  71. The following 3 properties are obviously on Action normally, but it's useful
  72. that logs are lightweight (so we don't need to have an action associated to
  73. logs, etc. Therefore, we duplicate those values here.
  74. */
  75. ActionTitle string
  76. ActionIcon string
  77. ActionId string
  78. }
  79. type executorStepFunc func(*ExecutionRequest) bool
  80. // DefaultExecutor returns an Executor, with a sensible "chain of command" for
  81. // executing actions.
  82. func DefaultExecutor() *Executor {
  83. e := Executor{}
  84. e.Logs = make(map[string]*InternalLogEntry)
  85. e.LogsByActionId = make(map[string][]*InternalLogEntry)
  86. e.chainOfCommand = []executorStepFunc{
  87. stepRequestAction,
  88. stepConcurrencyCheck,
  89. stepRateCheck,
  90. stepACLCheck,
  91. stepParseArgs,
  92. stepLogStart,
  93. stepExec,
  94. stepExecAfter,
  95. stepLogFinish,
  96. stepSaveLog,
  97. stepTrigger,
  98. }
  99. return &e
  100. }
  101. type listener interface {
  102. OnExecutionStarted(actionTitle string)
  103. OnExecutionFinished(logEntry *InternalLogEntry)
  104. }
  105. func (e *Executor) AddListener(m listener) {
  106. e.listeners = append(e.listeners, m)
  107. }
  108. // ExecRequest processes an ExecutionRequest
  109. func (e *Executor) ExecRequest(req *ExecutionRequest) (*sync.WaitGroup, string) {
  110. req.executor = e
  111. // req.UUID is now set by the client, so that they can track the request
  112. // from start to finish. This means that a malicious client could send
  113. // duplicate UUIDs (or just random strings), but this is the only way.
  114. req.logEntry = &InternalLogEntry{
  115. DatetimeStarted: time.Now(),
  116. ExecutionTrackingID: req.TrackingID,
  117. Stdout: "",
  118. Stderr: "",
  119. ExitCode: -1337, // If an Action is not actually executed, this is the default exit code.
  120. ExecutionStarted: false,
  121. ExecutionFinished: false,
  122. ActionId: "",
  123. ActionTitle: "notfound",
  124. ActionIcon: "💩",
  125. }
  126. _, foundLog := e.Logs[req.TrackingID]
  127. if foundLog || req.TrackingID == "" {
  128. req.TrackingID = uuid.NewString()
  129. }
  130. e.Logs[req.TrackingID] = req.logEntry
  131. wg := new(sync.WaitGroup)
  132. wg.Add(1)
  133. go func() {
  134. e.execChain(req)
  135. defer wg.Done()
  136. }()
  137. return wg, req.TrackingID
  138. }
  139. func (e *Executor) execChain(req *ExecutionRequest) {
  140. for _, step := range e.chainOfCommand {
  141. if !step(req) {
  142. break
  143. }
  144. }
  145. req.logEntry.ExecutionFinished = true
  146. // This isn't a step, because we want to notify all listeners, irrespective
  147. // of how many steps were actually executed.
  148. notifyListeners(req)
  149. }
  150. func getConcurrentCount(req *ExecutionRequest) int {
  151. concurrentCount := 0
  152. for _, log := range req.executor.LogsByActionId[req.Action.ID] {
  153. if !log.ExecutionFinished {
  154. concurrentCount += 1
  155. }
  156. }
  157. return concurrentCount
  158. }
  159. func stepConcurrencyCheck(req *ExecutionRequest) bool {
  160. concurrentCount := getConcurrentCount(req)
  161. // Note that the current execution is counted int the logs, so when checking we +1
  162. if concurrentCount >= (req.Action.MaxConcurrent + 1) {
  163. 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)
  164. log.WithFields(log.Fields{
  165. "actionTitle": req.logEntry.ActionTitle,
  166. }).Warnf(msg)
  167. req.logEntry.Stdout = msg
  168. req.logEntry.Blocked = true
  169. return false
  170. }
  171. return true
  172. }
  173. func parseDuration(rate config.RateSpec) time.Duration {
  174. duration, err := time.ParseDuration(rate.Duration)
  175. if err != nil {
  176. log.Warnf("Could not parse duration: %v", rate.Duration)
  177. return -1 * time.Minute
  178. }
  179. return duration
  180. }
  181. func getExecutionsCount(rate config.RateSpec, req *ExecutionRequest) int {
  182. executions := -1 // Because we will find ourself when checking execution logs
  183. duration := parseDuration(rate)
  184. then := time.Now().Add(-duration)
  185. for _, logEntry := range req.executor.LogsByActionId[req.Action.ID] {
  186. if logEntry.DatetimeStarted.After(then) && !logEntry.Blocked {
  187. executions += 1
  188. }
  189. }
  190. return executions
  191. }
  192. func stepRateCheck(req *ExecutionRequest) bool {
  193. for _, rate := range req.Action.MaxRate {
  194. executions := getExecutionsCount(rate, req)
  195. if executions >= rate.Limit {
  196. msg := fmt.Sprintf("Blocked from executing. This action has run %d out of %d allowed times in the last %s.", executions, rate.Limit, rate.Duration)
  197. log.WithFields(log.Fields{
  198. "actionTitle": req.logEntry.ActionTitle,
  199. }).Infof(msg)
  200. req.logEntry.Stdout = msg
  201. req.logEntry.Blocked = true
  202. return false
  203. }
  204. }
  205. return true
  206. }
  207. func stepACLCheck(req *ExecutionRequest) bool {
  208. return acl.IsAllowedExec(req.Cfg, req.AuthenticatedUser, req.Action)
  209. }
  210. func stepParseArgs(req *ExecutionRequest) bool {
  211. var err error
  212. req.finalParsedCommand, err = parseActionArguments(req.Action.Shell, req.Arguments, req.Action, req.logEntry.ActionTitle, req.EntityPrefix)
  213. if err != nil {
  214. req.logEntry.Stdout = err.Error()
  215. log.Warnf(err.Error())
  216. return false
  217. }
  218. return true
  219. }
  220. func stepRequestAction(req *ExecutionRequest) bool {
  221. // The grpc API always tries to find the action by ID, but it may
  222. if req.Action == nil {
  223. log.WithFields(log.Fields{
  224. "actionTitle": req.ActionTitle,
  225. }).Infof("Action finding by title")
  226. req.Action = req.Cfg.FindAction(req.ActionTitle)
  227. if req.Action == nil {
  228. log.WithFields(log.Fields{
  229. "actionTitle": req.ActionTitle,
  230. }).Warnf("Action requested, but not found")
  231. req.logEntry.Stderr = "Action not found: " + req.ActionTitle
  232. return false
  233. }
  234. }
  235. metricActionsRequested.Inc()
  236. req.logEntry.ActionTitle = sv.ReplaceEntityVars(req.EntityPrefix, req.Action.Title)
  237. req.logEntry.ActionIcon = req.Action.Icon
  238. req.logEntry.ActionId = req.Action.ID
  239. if _, containsKey := req.executor.LogsByActionId[req.Action.ID]; !containsKey {
  240. req.executor.LogsByActionId[req.Action.ID] = make([]*InternalLogEntry, 0)
  241. }
  242. req.executor.LogsByActionId[req.Action.ID] = append(req.executor.LogsByActionId[req.Action.ID], req.logEntry)
  243. log.WithFields(log.Fields{
  244. "actionTitle": req.logEntry.ActionTitle,
  245. "tags": req.Tags,
  246. }).Infof("Action requested")
  247. return true
  248. }
  249. func stepLogStart(req *ExecutionRequest) bool {
  250. log.WithFields(log.Fields{
  251. "actionTitle": req.logEntry.ActionTitle,
  252. "timeout": req.Action.Timeout,
  253. }).Infof("Action starting")
  254. return true
  255. }
  256. func stepLogFinish(req *ExecutionRequest) bool {
  257. req.logEntry.ExecutionFinished = true
  258. log.WithFields(log.Fields{
  259. "actionTitle": req.logEntry.ActionTitle,
  260. "stdout": req.logEntry.Stdout,
  261. "stderr": req.logEntry.Stderr,
  262. "timedOut": req.logEntry.TimedOut,
  263. "exit": req.logEntry.ExitCode,
  264. }).Infof("Action finished")
  265. return true
  266. }
  267. func notifyListeners(req *ExecutionRequest) {
  268. for _, listener := range req.executor.listeners {
  269. listener.OnExecutionFinished(req.logEntry)
  270. }
  271. }
  272. func wrapCommandInShell(ctx context.Context, finalParsedCommand string) *exec.Cmd {
  273. if runtime.GOOS == "windows" {
  274. return exec.CommandContext(ctx, "cmd", "/C", finalParsedCommand)
  275. }
  276. return exec.CommandContext(ctx, "sh", "-c", finalParsedCommand)
  277. }
  278. func appendErrorToStderr(err error, logEntry *InternalLogEntry) {
  279. if err != nil {
  280. logEntry.Stderr = err.Error() + "\n\n" + logEntry.Stderr
  281. }
  282. }
  283. func buildEnv(req *ExecutionRequest) []string {
  284. ret := append(os.Environ(), "OLIVETIN=1")
  285. for k, v := range req.Arguments {
  286. ret = append(ret, fmt.Sprintf("%v=%v", strings.ToUpper(k), v))
  287. }
  288. return ret
  289. }
  290. func stepExec(req *ExecutionRequest) bool {
  291. ctx, cancel := context.WithTimeout(context.Background(), time.Duration(req.Action.Timeout)*time.Second)
  292. defer cancel()
  293. var stdout bytes.Buffer
  294. var stderr bytes.Buffer
  295. cmd := wrapCommandInShell(ctx, req.finalParsedCommand)
  296. cmd.Env = buildEnv(req)
  297. cmd.Stdout = &stdout
  298. cmd.Stderr = &stderr
  299. req.logEntry.StdoutBuffer, _ = cmd.StdoutPipe()
  300. req.logEntry.StderrBuffer, _ = cmd.StderrPipe()
  301. req.logEntry.ExecutionStarted = true
  302. runerr := cmd.Start()
  303. req.logEntry.Process = cmd.Process
  304. waiterr := cmd.Wait()
  305. req.logEntry.ExitCode = int32(cmd.ProcessState.ExitCode())
  306. req.logEntry.Stdout = stdout.String()
  307. req.logEntry.Stderr = stderr.String()
  308. appendErrorToStderr(runerr, req.logEntry)
  309. appendErrorToStderr(waiterr, req.logEntry)
  310. if ctx.Err() == context.DeadlineExceeded {
  311. req.logEntry.TimedOut = true
  312. }
  313. req.logEntry.Tags = req.Tags
  314. req.logEntry.DatetimeFinished = time.Now()
  315. return true
  316. }
  317. func stepExecAfter(req *ExecutionRequest) bool {
  318. if req.Action.ShellAfterCompleted == "" {
  319. return true
  320. }
  321. ctx, cancel := context.WithTimeout(context.Background(), time.Duration(req.Action.Timeout)*time.Second)
  322. defer cancel()
  323. var stdout bytes.Buffer
  324. var stderr bytes.Buffer
  325. args := map[string]string{
  326. "stdout": req.logEntry.Stdout,
  327. "exitCode": fmt.Sprintf("%v", req.logEntry.ExitCode),
  328. }
  329. finalParsedCommand, _ := parseActionArguments(req.Action.ShellAfterCompleted, args, req.Action, req.logEntry.ActionTitle, req.EntityPrefix)
  330. cmd := wrapCommandInShell(ctx, finalParsedCommand)
  331. cmd.Stdout = &stdout
  332. cmd.Stderr = &stderr
  333. runerr := cmd.Start()
  334. waiterr := cmd.Wait()
  335. req.logEntry.Stdout += "---\n" + stdout.String()
  336. req.logEntry.Stderr += "---\n" + stderr.String()
  337. appendErrorToStderr(runerr, req.logEntry)
  338. appendErrorToStderr(waiterr, req.logEntry)
  339. if ctx.Err() == context.DeadlineExceeded {
  340. req.logEntry.Stderr += "Your shellAfterCommand command timed out."
  341. }
  342. req.logEntry.Stdout += fmt.Sprintf("Your shellAfterCommand exited with code %v", cmd.ProcessState.ExitCode())
  343. return true
  344. }
  345. func stepTrigger(req *ExecutionRequest) bool {
  346. if req.Action.Trigger != "" {
  347. trigger := &ExecutionRequest{
  348. ActionTitle: req.Action.Trigger,
  349. TrackingID: uuid.NewString(),
  350. Tags: []string{"trigger"},
  351. AuthenticatedUser: req.AuthenticatedUser,
  352. Cfg: req.Cfg,
  353. }
  354. req.executor.ExecRequest(trigger)
  355. }
  356. return true
  357. }
  358. func stepSaveLog(req *ExecutionRequest) bool {
  359. filename := fmt.Sprintf("%v.%v.%v", req.logEntry.ActionTitle, req.logEntry.DatetimeStarted.Unix(), req.logEntry.ExecutionTrackingID)
  360. saveLogResults(req, filename)
  361. saveLogOutput(req, filename)
  362. return true
  363. }
  364. func firstNonEmpty(one, two string) string {
  365. if one != "" {
  366. return one
  367. }
  368. return two
  369. }
  370. func saveLogResults(req *ExecutionRequest, filename string) {
  371. dir := firstNonEmpty(req.Action.SaveLogs.ResultsDirectory, req.Cfg.SaveLogs.ResultsDirectory)
  372. if dir != "" {
  373. data, err := yaml.Marshal(req.logEntry)
  374. if err != nil {
  375. log.Warnf("%v", err)
  376. }
  377. filepath := path.Join(dir, filename+".yaml")
  378. err = os.WriteFile(filepath, data, 0644)
  379. if err != nil {
  380. log.Warnf("%v", err)
  381. }
  382. }
  383. }
  384. func saveLogOutput(req *ExecutionRequest, filename string) {
  385. dir := firstNonEmpty(req.Action.SaveLogs.OutputDirectory, req.Cfg.SaveLogs.OutputDirectory)
  386. if dir != "" {
  387. data := req.logEntry.Stdout + "\n" + req.logEntry.Stderr
  388. filepath := path.Join(dir, filename+".log")
  389. err := os.WriteFile(filepath, []byte(data), 0644)
  390. if err != nil {
  391. log.Warnf("%v", err)
  392. }
  393. }
  394. }