executor.go 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621
  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. if req.Arguments == nil {
  256. req.Arguments = make(map[string]string)
  257. }
  258. req.Arguments["ot_executionTrackingId"] = req.TrackingID
  259. req.Arguments["ot_username"] = req.AuthenticatedUser.Username
  260. req.finalParsedCommand, err = parseActionArguments(req.Action.Shell, req.Arguments, req.Action, req.logEntry.ActionTitle, req.EntityPrefix)
  261. if err != nil {
  262. req.logEntry.Output = err.Error()
  263. log.Warnf(err.Error())
  264. return false
  265. }
  266. return true
  267. }
  268. func stepRequestAction(req *ExecutionRequest) bool {
  269. // The grpc API always tries to find the action by ID, but it may
  270. if req.Action == nil {
  271. log.WithFields(log.Fields{
  272. "actionTitle": req.ActionTitle,
  273. }).Infof("Action finding by title")
  274. req.Action = req.Cfg.FindAction(req.ActionTitle)
  275. if req.Action == nil {
  276. log.WithFields(log.Fields{
  277. "actionTitle": req.ActionTitle,
  278. }).Warnf("Action requested, but not found")
  279. req.logEntry.Output = "Action not found: " + req.ActionTitle
  280. return false
  281. }
  282. }
  283. metricActionsRequested.Inc()
  284. req.logEntry.ActionTitle = sv.ReplaceEntityVars(req.EntityPrefix, req.Action.Title)
  285. req.logEntry.ActionIcon = req.Action.Icon
  286. req.logEntry.ActionId = req.Action.ID
  287. req.logEntry.Tags = req.Tags
  288. req.executor.logmutex.Lock()
  289. if _, containsKey := req.executor.LogsByActionId[req.Action.ID]; !containsKey {
  290. req.executor.LogsByActionId[req.Action.ID] = make([]*InternalLogEntry, 0)
  291. }
  292. req.executor.LogsByActionId[req.Action.ID] = append(req.executor.LogsByActionId[req.Action.ID], req.logEntry)
  293. req.executor.logmutex.Unlock()
  294. log.WithFields(log.Fields{
  295. "actionTitle": req.logEntry.ActionTitle,
  296. "tags": req.Tags,
  297. }).Infof("Action requested")
  298. return true
  299. }
  300. func stepLogStart(req *ExecutionRequest) bool {
  301. log.WithFields(log.Fields{
  302. "actionTitle": req.logEntry.ActionTitle,
  303. "timeout": req.Action.Timeout,
  304. }).Infof("Action started")
  305. return true
  306. }
  307. func stepLogFinish(req *ExecutionRequest) bool {
  308. req.logEntry.ExecutionFinished = true
  309. log.WithFields(log.Fields{
  310. "actionTitle": req.logEntry.ActionTitle,
  311. "outputLength": len(req.logEntry.Output),
  312. "timedOut": req.logEntry.TimedOut,
  313. "exit": req.logEntry.ExitCode,
  314. }).Infof("Action finished")
  315. return true
  316. }
  317. func notifyListeners(req *ExecutionRequest) {
  318. for _, listener := range req.executor.listeners {
  319. listener.OnExecutionFinished(req.logEntry)
  320. }
  321. }
  322. func appendErrorToStderr(err error, logEntry *InternalLogEntry) {
  323. if err != nil {
  324. logEntry.Output = err.Error() + "\n\n" + logEntry.Output
  325. }
  326. }
  327. type OutputStreamer struct {
  328. Req *ExecutionRequest
  329. output bytes.Buffer
  330. }
  331. func (ost *OutputStreamer) Write(o []byte) (n int, err error) {
  332. for _, listener := range ost.Req.executor.listeners {
  333. listener.OnOutputChunk(o, ost.Req.TrackingID)
  334. }
  335. return ost.output.Write(o)
  336. }
  337. func (ost *OutputStreamer) String() string {
  338. return ost.output.String()
  339. }
  340. func buildEnv(req *ExecutionRequest) []string {
  341. ret := append(os.Environ(), "OLIVETIN=1")
  342. for k, v := range req.Arguments {
  343. varName := fmt.Sprintf("%v", strings.TrimSpace(strings.ToUpper(k)))
  344. // Skip arguments that might not have a name (eg, confirmation), as this causes weird bugs on Windows.
  345. if varName == "" {
  346. continue
  347. }
  348. ret = append(ret, fmt.Sprintf("%v=%v", varName, v))
  349. }
  350. return ret
  351. }
  352. func stepExec(req *ExecutionRequest) bool {
  353. ctx, cancel := context.WithTimeout(context.Background(), time.Duration(req.Action.Timeout)*time.Second)
  354. defer cancel()
  355. streamer := &OutputStreamer{Req: req}
  356. cmd := wrapCommandInShell(ctx, req.finalParsedCommand)
  357. cmd.Stdout = streamer
  358. cmd.Stderr = streamer
  359. cmd.Env = buildEnv(req)
  360. req.logEntry.ExecutionStarted = true
  361. runerr := cmd.Start()
  362. req.logEntry.Process = cmd.Process
  363. waiterr := cmd.Wait()
  364. req.logEntry.ExitCode = int32(cmd.ProcessState.ExitCode())
  365. req.logEntry.Output = streamer.String()
  366. appendErrorToStderr(runerr, req.logEntry)
  367. appendErrorToStderr(waiterr, req.logEntry)
  368. if ctx.Err() == context.DeadlineExceeded {
  369. log.WithFields(log.Fields{
  370. "actionTitle": req.logEntry.ActionTitle,
  371. }).Warnf("Action timed out")
  372. // The context timeout should kill the process, but let's make sure.
  373. req.executor.Kill(req.logEntry)
  374. req.logEntry.TimedOut = true
  375. req.logEntry.Output += "OliveTin::timeout - this 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."
  376. }
  377. req.logEntry.DatetimeFinished = time.Now()
  378. return true
  379. }
  380. func stepExecAfter(req *ExecutionRequest) bool {
  381. if req.Action.ShellAfterCompleted == "" {
  382. return true
  383. }
  384. ctx, cancel := context.WithTimeout(context.Background(), time.Duration(req.Action.Timeout)*time.Second)
  385. defer cancel()
  386. var stdout bytes.Buffer
  387. var stderr bytes.Buffer
  388. args := map[string]string{
  389. "output": req.logEntry.Output,
  390. "exitCode": fmt.Sprintf("%v", req.logEntry.ExitCode),
  391. }
  392. finalParsedCommand, _ := parseActionArguments(req.Action.ShellAfterCompleted, args, req.Action, req.logEntry.ActionTitle, req.EntityPrefix)
  393. cmd := wrapCommandInShell(ctx, finalParsedCommand)
  394. cmd.Stdout = &stdout
  395. cmd.Stderr = &stderr
  396. runerr := cmd.Start()
  397. waiterr := cmd.Wait()
  398. req.logEntry.Output += "\n" + stdout.String()
  399. req.logEntry.Output += "OliveTin::shellAfterCompleted stdout\n" + stdout.String()
  400. req.logEntry.Output += stdout.String()
  401. req.logEntry.Output += "OliveTin::shellAfterCompleted stderr\n" + stdout.String()
  402. req.logEntry.Output += stderr.String()
  403. req.logEntry.Output += "OliveTin::shellAfterCompleted errors and summary\n" + stdout.String()
  404. appendErrorToStderr(runerr, req.logEntry)
  405. appendErrorToStderr(waiterr, req.logEntry)
  406. if ctx.Err() == context.DeadlineExceeded {
  407. req.logEntry.Output += "Your shellAfterCompleted command timed out."
  408. }
  409. req.logEntry.Output += fmt.Sprintf("Your shellAfterCompleted exited with code %v\n", cmd.ProcessState.ExitCode())
  410. req.logEntry.Output += "OliveTin::shellAfterCompleted output complete\n" + stdout.String()
  411. return true
  412. }
  413. func stepTrigger(req *ExecutionRequest) bool {
  414. if req.Action.Trigger != "" {
  415. trigger := &ExecutionRequest{
  416. ActionTitle: req.Action.Trigger,
  417. TrackingID: uuid.NewString(),
  418. Tags: []string{"trigger"},
  419. AuthenticatedUser: req.AuthenticatedUser,
  420. Cfg: req.Cfg,
  421. }
  422. req.executor.ExecRequest(trigger)
  423. }
  424. return true
  425. }
  426. func stepSaveLog(req *ExecutionRequest) bool {
  427. filename := fmt.Sprintf("%v.%v.%v", req.logEntry.ActionTitle, req.logEntry.DatetimeStarted.Unix(), req.logEntry.ExecutionTrackingID)
  428. saveLogResults(req, filename)
  429. saveLogOutput(req, filename)
  430. return true
  431. }
  432. func firstNonEmpty(one, two string) string {
  433. if one != "" {
  434. return one
  435. }
  436. return two
  437. }
  438. func saveLogResults(req *ExecutionRequest, filename string) {
  439. dir := firstNonEmpty(req.Action.SaveLogs.ResultsDirectory, req.Cfg.SaveLogs.ResultsDirectory)
  440. if dir != "" {
  441. data, err := yaml.Marshal(req.logEntry)
  442. if err != nil {
  443. log.Warnf("%v", err)
  444. }
  445. filepath := path.Join(dir, filename+".yaml")
  446. err = os.WriteFile(filepath, data, 0644)
  447. if err != nil {
  448. log.Warnf("%v", err)
  449. }
  450. }
  451. }
  452. func saveLogOutput(req *ExecutionRequest, filename string) {
  453. dir := firstNonEmpty(req.Action.SaveLogs.OutputDirectory, req.Cfg.SaveLogs.OutputDirectory)
  454. if dir != "" {
  455. data := req.logEntry.Output
  456. filepath := path.Join(dir, filename+".log")
  457. err := os.WriteFile(filepath, []byte(data), 0644)
  458. if err != nil {
  459. log.Warnf("%v", err)
  460. }
  461. }
  462. }