executor.go 13 KB

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