executor.go 13 KB

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