executor.go 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539
  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.NewGauge(prometheus.GaugeOpts{
  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.UUID is now set by the client, so that they can track the request
  118. // from start to finish. This means that a malicious client could send
  119. // duplicate UUIDs (or just random strings), but this is the only way.
  120. req.logEntry = &InternalLogEntry{
  121. DatetimeStarted: time.Now(),
  122. ExecutionTrackingID: req.TrackingID,
  123. Output: "",
  124. ExitCode: -1337, // If an Action is not actually executed, this is the default exit code.
  125. ExecutionStarted: false,
  126. ExecutionFinished: false,
  127. ActionId: "",
  128. ActionTitle: "notfound",
  129. ActionIcon: "💩",
  130. }
  131. _, foundLog := e.Logs[req.TrackingID]
  132. if foundLog || req.TrackingID == "" {
  133. req.TrackingID = uuid.NewString()
  134. }
  135. e.Logs[req.TrackingID] = req.logEntry
  136. wg := new(sync.WaitGroup)
  137. wg.Add(1)
  138. go func() {
  139. e.execChain(req)
  140. defer wg.Done()
  141. }()
  142. return wg, req.TrackingID
  143. }
  144. func (e *Executor) execChain(req *ExecutionRequest) {
  145. for _, step := range e.chainOfCommand {
  146. if !step(req) {
  147. break
  148. }
  149. }
  150. req.logEntry.ExecutionFinished = true
  151. // This isn't a step, because we want to notify all listeners, irrespective
  152. // of how many steps were actually executed.
  153. notifyListeners(req)
  154. }
  155. func getConcurrentCount(req *ExecutionRequest) int {
  156. concurrentCount := 0
  157. for _, log := range req.executor.LogsByActionId[req.Action.ID] {
  158. if !log.ExecutionFinished {
  159. concurrentCount += 1
  160. }
  161. }
  162. return concurrentCount
  163. }
  164. func stepConcurrencyCheck(req *ExecutionRequest) bool {
  165. concurrentCount := getConcurrentCount(req)
  166. // Note that the current execution is counted int the logs, so when checking we +1
  167. if concurrentCount >= (req.Action.MaxConcurrent + 1) {
  168. 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)
  169. log.WithFields(log.Fields{
  170. "actionTitle": req.logEntry.ActionTitle,
  171. }).Warnf(msg)
  172. req.logEntry.Output = msg
  173. req.logEntry.Blocked = true
  174. return false
  175. }
  176. return true
  177. }
  178. func parseDuration(rate config.RateSpec) time.Duration {
  179. duration, err := time.ParseDuration(rate.Duration)
  180. if err != nil {
  181. log.Warnf("Could not parse duration: %v", rate.Duration)
  182. return -1 * time.Minute
  183. }
  184. return duration
  185. }
  186. func getExecutionsCount(rate config.RateSpec, req *ExecutionRequest) int {
  187. executions := -1 // Because we will find ourself when checking execution logs
  188. duration := parseDuration(rate)
  189. then := time.Now().Add(-duration)
  190. for _, logEntry := range req.executor.LogsByActionId[req.Action.ID] {
  191. if logEntry.DatetimeStarted.After(then) && !logEntry.Blocked {
  192. executions += 1
  193. }
  194. }
  195. return executions
  196. }
  197. func stepRateCheck(req *ExecutionRequest) bool {
  198. for _, rate := range req.Action.MaxRate {
  199. executions := getExecutionsCount(rate, req)
  200. if executions >= rate.Limit {
  201. 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)
  202. log.WithFields(log.Fields{
  203. "actionTitle": req.logEntry.ActionTitle,
  204. }).Infof(msg)
  205. req.logEntry.Output = msg
  206. req.logEntry.Blocked = true
  207. return false
  208. }
  209. }
  210. return true
  211. }
  212. func stepACLCheck(req *ExecutionRequest) bool {
  213. return acl.IsAllowedExec(req.Cfg, req.AuthenticatedUser, req.Action)
  214. }
  215. func stepParseArgs(req *ExecutionRequest) bool {
  216. var err error
  217. req.finalParsedCommand, err = parseActionArguments(req.Action.Shell, req.Arguments, req.Action, req.logEntry.ActionTitle, req.EntityPrefix)
  218. if err != nil {
  219. req.logEntry.Output = err.Error()
  220. log.Warnf(err.Error())
  221. return false
  222. }
  223. return true
  224. }
  225. func stepRequestAction(req *ExecutionRequest) bool {
  226. // The grpc API always tries to find the action by ID, but it may
  227. if req.Action == nil {
  228. log.WithFields(log.Fields{
  229. "actionTitle": req.ActionTitle,
  230. }).Infof("Action finding by title")
  231. req.Action = req.Cfg.FindAction(req.ActionTitle)
  232. if req.Action == nil {
  233. log.WithFields(log.Fields{
  234. "actionTitle": req.ActionTitle,
  235. }).Warnf("Action requested, but not found")
  236. req.logEntry.Output = "Action not found: " + req.ActionTitle
  237. return false
  238. }
  239. }
  240. metricActionsRequested.Inc()
  241. req.logEntry.ActionTitle = sv.ReplaceEntityVars(req.EntityPrefix, req.Action.Title)
  242. req.logEntry.ActionIcon = req.Action.Icon
  243. req.logEntry.ActionId = req.Action.ID
  244. if _, containsKey := req.executor.LogsByActionId[req.Action.ID]; !containsKey {
  245. req.executor.LogsByActionId[req.Action.ID] = make([]*InternalLogEntry, 0)
  246. }
  247. req.executor.LogsByActionId[req.Action.ID] = append(req.executor.LogsByActionId[req.Action.ID], req.logEntry)
  248. log.WithFields(log.Fields{
  249. "actionTitle": req.logEntry.ActionTitle,
  250. "tags": req.Tags,
  251. }).Infof("Action requested")
  252. return true
  253. }
  254. func stepLogStart(req *ExecutionRequest) bool {
  255. log.WithFields(log.Fields{
  256. "actionTitle": req.logEntry.ActionTitle,
  257. "timeout": req.Action.Timeout,
  258. }).Infof("Action starting")
  259. return true
  260. }
  261. func stepLogFinish(req *ExecutionRequest) bool {
  262. req.logEntry.ExecutionFinished = true
  263. log.WithFields(log.Fields{
  264. "actionTitle": req.logEntry.ActionTitle,
  265. "outputLength": len(req.logEntry.Output),
  266. "timedOut": req.logEntry.TimedOut,
  267. "exit": req.logEntry.ExitCode,
  268. }).Infof("Action finished")
  269. return true
  270. }
  271. func notifyListeners(req *ExecutionRequest) {
  272. for _, listener := range req.executor.listeners {
  273. listener.OnExecutionFinished(req.logEntry)
  274. }
  275. }
  276. func appendErrorToStderr(err error, logEntry *InternalLogEntry) {
  277. if err != nil {
  278. logEntry.Output = err.Error() + "\n\n" + logEntry.Output
  279. }
  280. }
  281. type OutputStreamer struct {
  282. Req *ExecutionRequest
  283. output bytes.Buffer
  284. }
  285. func (ost *OutputStreamer) Write(o []byte) (n int, err error) {
  286. for _, listener := range ost.Req.executor.listeners {
  287. listener.OnOutputChunk(o, ost.Req.TrackingID)
  288. }
  289. return ost.output.Write(o)
  290. }
  291. func (ost *OutputStreamer) String() string {
  292. return ost.output.String()
  293. }
  294. func buildEnv(req *ExecutionRequest) []string {
  295. ret := append(os.Environ(), "OLIVETIN=1")
  296. for k, v := range req.Arguments {
  297. varName := fmt.Sprintf("%v", strings.TrimSpace(strings.ToUpper(k)))
  298. // Skip arguments that might not have a name (eg, confirmation), as this causes weird bugs on Windows.
  299. if varName == "" {
  300. continue
  301. }
  302. ret = append(ret, fmt.Sprintf("%v=%v", varName, v))
  303. }
  304. return ret
  305. }
  306. func stepExec(req *ExecutionRequest) bool {
  307. ctx, cancel := context.WithTimeout(context.Background(), time.Duration(req.Action.Timeout)*time.Second)
  308. defer cancel()
  309. streamer := &OutputStreamer{Req: req}
  310. cmd := wrapCommandInShell(ctx, req.finalParsedCommand)
  311. cmd.Stdout = streamer
  312. cmd.Stderr = streamer
  313. cmd.Env = buildEnv(req)
  314. req.logEntry.ExecutionStarted = true
  315. runerr := cmd.Start()
  316. req.logEntry.Process = cmd.Process
  317. waiterr := cmd.Wait()
  318. req.logEntry.ExitCode = int32(cmd.ProcessState.ExitCode())
  319. req.logEntry.Output = streamer.String()
  320. appendErrorToStderr(runerr, req.logEntry)
  321. appendErrorToStderr(waiterr, req.logEntry)
  322. if ctx.Err() == context.DeadlineExceeded {
  323. log.Warnf("Command timed out: %v", req.finalParsedCommand)
  324. // The context timeout should kill the process, but let's make sure.
  325. req.executor.Kill(req.logEntry)
  326. req.logEntry.TimedOut = true
  327. }
  328. req.logEntry.Tags = req.Tags
  329. req.logEntry.DatetimeFinished = time.Now()
  330. return true
  331. }
  332. func stepExecAfter(req *ExecutionRequest) bool {
  333. if req.Action.ShellAfterCompleted == "" {
  334. return true
  335. }
  336. ctx, cancel := context.WithTimeout(context.Background(), time.Duration(req.Action.Timeout)*time.Second)
  337. defer cancel()
  338. var stdout bytes.Buffer
  339. var stderr bytes.Buffer
  340. args := map[string]string{
  341. "output": req.logEntry.Output,
  342. "exitCode": fmt.Sprintf("%v", req.logEntry.ExitCode),
  343. }
  344. finalParsedCommand, _ := parseActionArguments(req.Action.ShellAfterCompleted, args, req.Action, req.logEntry.ActionTitle, req.EntityPrefix)
  345. cmd := wrapCommandInShell(ctx, finalParsedCommand)
  346. cmd.Stdout = &stdout
  347. cmd.Stderr = &stderr
  348. runerr := cmd.Start()
  349. waiterr := cmd.Wait()
  350. req.logEntry.Output += "---\n" + stdout.String()
  351. req.logEntry.Output += "---\n" + stderr.String()
  352. appendErrorToStderr(runerr, req.logEntry)
  353. appendErrorToStderr(waiterr, req.logEntry)
  354. if ctx.Err() == context.DeadlineExceeded {
  355. req.logEntry.Output += "Your shellAfterCommand command timed out."
  356. }
  357. req.logEntry.Output += fmt.Sprintf("Your shellAfterCommand exited with code %v", cmd.ProcessState.ExitCode())
  358. return true
  359. }
  360. func stepTrigger(req *ExecutionRequest) bool {
  361. if req.Action.Trigger != "" {
  362. trigger := &ExecutionRequest{
  363. ActionTitle: req.Action.Trigger,
  364. TrackingID: uuid.NewString(),
  365. Tags: []string{"trigger"},
  366. AuthenticatedUser: req.AuthenticatedUser,
  367. Cfg: req.Cfg,
  368. }
  369. req.executor.ExecRequest(trigger)
  370. }
  371. return true
  372. }
  373. func stepSaveLog(req *ExecutionRequest) bool {
  374. filename := fmt.Sprintf("%v.%v.%v", req.logEntry.ActionTitle, req.logEntry.DatetimeStarted.Unix(), req.logEntry.ExecutionTrackingID)
  375. saveLogResults(req, filename)
  376. saveLogOutput(req, filename)
  377. return true
  378. }
  379. func firstNonEmpty(one, two string) string {
  380. if one != "" {
  381. return one
  382. }
  383. return two
  384. }
  385. func saveLogResults(req *ExecutionRequest, filename string) {
  386. dir := firstNonEmpty(req.Action.SaveLogs.ResultsDirectory, req.Cfg.SaveLogs.ResultsDirectory)
  387. if dir != "" {
  388. data, err := yaml.Marshal(req.logEntry)
  389. if err != nil {
  390. log.Warnf("%v", err)
  391. }
  392. filepath := path.Join(dir, filename+".yaml")
  393. err = os.WriteFile(filepath, data, 0644)
  394. if err != nil {
  395. log.Warnf("%v", err)
  396. }
  397. }
  398. }
  399. func saveLogOutput(req *ExecutionRequest, filename string) {
  400. dir := firstNonEmpty(req.Action.SaveLogs.OutputDirectory, req.Cfg.SaveLogs.OutputDirectory)
  401. if dir != "" {
  402. data := req.logEntry.Output
  403. filepath := path.Join(dir, filename+".log")
  404. err := os.WriteFile(filepath, []byte(data), 0644)
  405. if err != nil {
  406. log.Warnf("%v", err)
  407. }
  408. }
  409. }