4
0

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