executor.go 14 KB

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