4
0

executor.go 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754
  1. package executor
  2. import (
  3. acl "github.com/OliveTin/OliveTin/internal/acl"
  4. config "github.com/OliveTin/OliveTin/internal/config"
  5. "github.com/OliveTin/OliveTin/internal/entities"
  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. const (
  21. DefaultExitCodeNotExecuted = -1337
  22. MaxTriggerDepth = 10
  23. )
  24. var (
  25. metricActionsRequested = promauto.NewCounter(prometheus.CounterOpts{
  26. Name: "olivetin_actions_requested_count",
  27. Help: "The actions requested count",
  28. })
  29. )
  30. type ActionBinding struct {
  31. ID string
  32. Action *config.Action
  33. Entity *entities.Entity
  34. ConfigOrder int
  35. IsOnDashboard bool
  36. }
  37. // Executor represents a helper class for executing commands. It's main method
  38. // is ExecRequest
  39. type Executor struct {
  40. logs map[string]*InternalLogEntry
  41. logsTrackingIdsByDate []string
  42. LogsByActionId map[string][]*InternalLogEntry
  43. logmutex sync.RWMutex
  44. MapActionIdToBinding map[string]*ActionBinding
  45. MapActionIdToBindingLock sync.RWMutex
  46. Cfg *config.Config
  47. listeners []listener
  48. chainOfCommand []executorStepFunc
  49. }
  50. // ExecutionRequest is a request to execute an action. It's passed to an
  51. // Executor. They're created from the api.
  52. type ExecutionRequest struct {
  53. Binding *ActionBinding
  54. Arguments map[string]string
  55. TrackingID string
  56. Tags []string
  57. Cfg *config.Config
  58. AuthenticatedUser *acl.AuthenticatedUser
  59. TriggerDepth int
  60. logEntry *InternalLogEntry
  61. finalParsedCommand string
  62. executor *Executor
  63. }
  64. // InternalLogEntry objects are created by an Executor, and represent the final
  65. // state of execution (even if the command is not executed). It's designed to be
  66. // easily serializable.
  67. type InternalLogEntry struct {
  68. Binding *ActionBinding
  69. BindingID string
  70. DatetimeStarted time.Time
  71. DatetimeFinished time.Time
  72. Output string
  73. TimedOut bool
  74. Blocked bool
  75. ExitCode int32
  76. Tags []string
  77. ExecutionStarted bool
  78. ExecutionFinished bool
  79. ExecutionTrackingID string
  80. Process *os.Process
  81. Username string
  82. Index int64
  83. EntityPrefix string
  84. ActionConfigTitle string // This is the title of the action as defined in the config, not the final parsed title.
  85. /*
  86. The following 3 properties are obviously on Action normally, but it's useful
  87. that logs are lightweight (so we don't need to have an action associated to
  88. logs, etc. Therefore, we duplicate those values here.
  89. */
  90. ActionTitle string
  91. ActionIcon string
  92. ActionId string
  93. }
  94. type executorStepFunc func(*ExecutionRequest) bool
  95. // DefaultExecutor returns an Executor, with a sensible "chain of command" for
  96. // executing actions.
  97. func DefaultExecutor(cfg *config.Config) *Executor {
  98. e := Executor{}
  99. e.Cfg = cfg
  100. e.logs = make(map[string]*InternalLogEntry)
  101. e.logsTrackingIdsByDate = make([]string, 0)
  102. e.LogsByActionId = make(map[string][]*InternalLogEntry)
  103. e.MapActionIdToBinding = make(map[string]*ActionBinding)
  104. e.chainOfCommand = []executorStepFunc{
  105. stepRequestAction,
  106. stepConcurrencyCheck,
  107. stepRateCheck,
  108. stepACLCheck,
  109. stepParseArgs,
  110. stepLogStart,
  111. stepExec,
  112. stepExecAfter,
  113. stepLogFinish,
  114. stepSaveLog,
  115. stepTrigger,
  116. }
  117. return &e
  118. }
  119. type listener interface {
  120. OnExecutionStarted(logEntry *InternalLogEntry)
  121. OnExecutionFinished(logEntry *InternalLogEntry)
  122. OnOutputChunk(o []byte, executionTrackingId string)
  123. OnActionMapRebuilt()
  124. }
  125. func (e *Executor) AddListener(m listener) {
  126. e.listeners = append(e.listeners, m)
  127. }
  128. // getPagingStartIndex calculates the starting index for log pagination.
  129. // Parameters:
  130. //
  131. // startOffset: The offset from the most recent log (0 means start from the most recent)
  132. // totalLogCount: Total number of logs available
  133. // count: Number of logs to retrieve
  134. //
  135. // Returns: The calculated starting index for pagination
  136. func getPagingStartIndex(startOffset int64, totalLogCount int64) int64 {
  137. var startIndex int64
  138. if startOffset <= 0 {
  139. startIndex = totalLogCount
  140. } else {
  141. startIndex = (totalLogCount - startOffset)
  142. if startIndex < 0 {
  143. startIndex = 1
  144. }
  145. }
  146. return startIndex - 1
  147. }
  148. type PagingResult struct {
  149. CountRemaining int64
  150. PageSize int64
  151. TotalCount int64
  152. StartOffset int64
  153. }
  154. func (e *Executor) GetLogTrackingIds(startOffset int64, pageCount int64) ([]*InternalLogEntry, *PagingResult) {
  155. pagingResult := &PagingResult{
  156. CountRemaining: 0,
  157. PageSize: pageCount,
  158. TotalCount: 0,
  159. StartOffset: startOffset,
  160. }
  161. e.logmutex.RLock()
  162. totalLogCount := int64(len(e.logsTrackingIdsByDate))
  163. pagingResult.TotalCount = totalLogCount
  164. startIndex := getPagingStartIndex(startOffset, totalLogCount)
  165. pageCount = min(totalLogCount, pageCount)
  166. endIndex := max(0, (startIndex-pageCount)+1)
  167. log.WithFields(log.Fields{
  168. "startOffset": startOffset,
  169. "pageCount": pageCount,
  170. "total": totalLogCount,
  171. "startIndex": startIndex,
  172. "endIndex": endIndex,
  173. }).Tracef("GetLogTrackingIds")
  174. trackingIds := make([]*InternalLogEntry, 0, pageCount)
  175. if totalLogCount > 0 {
  176. for i := endIndex; i <= startIndex; i++ {
  177. trackingIds = append(trackingIds, e.logs[e.logsTrackingIdsByDate[i]])
  178. }
  179. }
  180. e.logmutex.RUnlock()
  181. pagingResult.CountRemaining = endIndex
  182. return trackingIds, pagingResult
  183. }
  184. func (e *Executor) GetLog(trackingID string) (*InternalLogEntry, bool) {
  185. e.logmutex.RLock()
  186. entry, found := e.logs[trackingID]
  187. e.logmutex.RUnlock()
  188. return entry, found
  189. }
  190. func (e *Executor) GetLogsByActionId(actionId string) []*InternalLogEntry {
  191. e.logmutex.RLock()
  192. logs, found := e.LogsByActionId[actionId]
  193. e.logmutex.RUnlock()
  194. if !found {
  195. return make([]*InternalLogEntry, 0)
  196. }
  197. return logs
  198. }
  199. func (e *Executor) SetLog(trackingID string, entry *InternalLogEntry) {
  200. e.logmutex.Lock()
  201. entry.Index = int64(len(e.logsTrackingIdsByDate))
  202. e.logs[trackingID] = entry
  203. e.logsTrackingIdsByDate = append(e.logsTrackingIdsByDate, trackingID)
  204. e.logmutex.Unlock()
  205. }
  206. // ExecRequest processes an ExecutionRequest
  207. func (e *Executor) ExecRequest(req *ExecutionRequest) (*sync.WaitGroup, string) {
  208. if req.AuthenticatedUser == nil {
  209. req.AuthenticatedUser = acl.UserGuest(req.Cfg)
  210. }
  211. req.executor = e
  212. req.logEntry = &InternalLogEntry{
  213. Binding: req.Binding,
  214. DatetimeStarted: time.Now(),
  215. ExecutionTrackingID: req.TrackingID,
  216. Output: "",
  217. ExitCode: DefaultExitCodeNotExecuted,
  218. ExecutionStarted: false,
  219. ExecutionFinished: false,
  220. ActionId: "",
  221. ActionTitle: "notfound",
  222. ActionIcon: "&#x1f4a9;",
  223. Username: req.AuthenticatedUser.Username,
  224. }
  225. _, isDuplicate := e.GetLog(req.TrackingID)
  226. if isDuplicate || req.TrackingID == "" {
  227. req.TrackingID = uuid.NewString()
  228. }
  229. log.Tracef("executor.ExecRequest(): %v", req)
  230. e.SetLog(req.TrackingID, req.logEntry)
  231. wg := new(sync.WaitGroup)
  232. wg.Add(1)
  233. go func() {
  234. e.execChain(req)
  235. defer wg.Done()
  236. }()
  237. return wg, req.TrackingID
  238. }
  239. func (e *Executor) execChain(req *ExecutionRequest) {
  240. for _, step := range e.chainOfCommand {
  241. if !step(req) {
  242. break
  243. }
  244. }
  245. req.logEntry.ExecutionFinished = true
  246. // This isn't a step, because we want to notify all listeners, irrespective
  247. // of how many steps were actually executed.
  248. notifyListenersFinished(req)
  249. }
  250. func getConcurrentCount(req *ExecutionRequest) int {
  251. concurrentCount := 0
  252. req.executor.logmutex.RLock()
  253. for _, log := range req.executor.GetLogsByActionId(req.Binding.Action.ID) {
  254. if !log.ExecutionFinished {
  255. concurrentCount += 1
  256. }
  257. }
  258. req.executor.logmutex.RUnlock()
  259. return concurrentCount
  260. }
  261. func stepConcurrencyCheck(req *ExecutionRequest) bool {
  262. concurrentCount := getConcurrentCount(req)
  263. // Note that the current execution is counted int the logs, so when checking we +1
  264. if concurrentCount >= (req.Binding.Action.MaxConcurrent + 1) {
  265. log.WithFields(log.Fields{
  266. "actionTitle": req.logEntry.ActionTitle,
  267. "concurrentCount": concurrentCount,
  268. "maxConcurrent": req.Binding.Action.MaxConcurrent,
  269. }).Warnf("Blocked from executing due to concurrency limit")
  270. req.logEntry.Output = "Blocked from executing due to concurrency limit"
  271. req.logEntry.Blocked = true
  272. return false
  273. }
  274. return true
  275. }
  276. func parseDuration(rate config.RateSpec) time.Duration {
  277. duration, err := time.ParseDuration(rate.Duration)
  278. if err != nil {
  279. log.Warnf("Could not parse duration: %v", rate.Duration)
  280. return -1 * time.Minute
  281. }
  282. return duration
  283. }
  284. //gocyclo:ignore
  285. func getExecutionsCount(rate config.RateSpec, req *ExecutionRequest) int {
  286. executions := -1 // Because we will find ourself when checking execution logs
  287. duration := parseDuration(rate)
  288. then := time.Now().Add(-duration)
  289. for _, logEntry := range req.executor.GetLogsByActionId(req.Binding.Action.ID) {
  290. // FIXME
  291. /*
  292. if logEntry.EntityPrefix != req.EntityPrefix {
  293. continue
  294. }
  295. */
  296. if logEntry.DatetimeStarted.After(then) && !logEntry.Blocked {
  297. executions += 1
  298. }
  299. }
  300. return executions
  301. }
  302. func stepRateCheck(req *ExecutionRequest) bool {
  303. for _, rate := range req.Binding.Action.MaxRate {
  304. executions := getExecutionsCount(rate, req)
  305. if executions >= rate.Limit {
  306. log.WithFields(log.Fields{
  307. "actionTitle": req.logEntry.ActionTitle,
  308. "executions": executions,
  309. "limit": rate.Limit,
  310. "duration": rate.Duration,
  311. }).Infof("Blocked from executing due to rate limit")
  312. req.logEntry.Output = "Blocked from executing due to rate limit"
  313. req.logEntry.Blocked = true
  314. return false
  315. }
  316. }
  317. return true
  318. }
  319. func stepACLCheck(req *ExecutionRequest) bool {
  320. canExec := acl.IsAllowedExec(req.Cfg, req.AuthenticatedUser, req.Binding.Action)
  321. if !canExec {
  322. req.logEntry.Output = "ACL check failed. Blocked from executing."
  323. req.logEntry.Blocked = true
  324. log.WithFields(log.Fields{
  325. "actionTitle": req.logEntry.ActionTitle,
  326. }).Warnf("ACL check failed. Blocked from executing.")
  327. }
  328. return canExec
  329. }
  330. func stepParseArgs(req *ExecutionRequest) bool {
  331. var err error
  332. if req.Arguments == nil {
  333. req.Arguments = make(map[string]string)
  334. }
  335. req.Arguments["ot_executionTrackingId"] = req.TrackingID
  336. req.Arguments["ot_username"] = req.AuthenticatedUser.Username
  337. mangleInvalidArgumentValues(req)
  338. req.finalParsedCommand, err = parseActionArguments(req.Arguments, req.Binding.Action, req.Binding.Entity)
  339. if err != nil {
  340. req.logEntry.Output = err.Error()
  341. log.Warn(err.Error())
  342. return false
  343. }
  344. return true
  345. }
  346. func stepRequestAction(req *ExecutionRequest) bool {
  347. metricActionsRequested.Inc()
  348. // If there is no binding or action, do not proceed. Leave default
  349. // log entry values (icon/title/id) and stop execution gracefully.
  350. if req.Binding == nil || req.Binding.Action == nil {
  351. log.Warnf("Action request has no binding/action; skipping execution")
  352. return false
  353. }
  354. req.logEntry.ActionConfigTitle = req.Binding.Action.Title
  355. req.logEntry.ActionTitle = entities.ParseTemplateWith(req.Binding.Action.Title, req.Binding.Entity)
  356. req.logEntry.ActionIcon = req.Binding.Action.Icon
  357. req.logEntry.ActionId = req.Binding.Action.ID
  358. req.logEntry.Tags = req.Tags
  359. req.executor.logmutex.Lock()
  360. if _, containsKey := req.executor.LogsByActionId[req.Binding.Action.ID]; !containsKey {
  361. req.executor.LogsByActionId[req.Binding.Action.ID] = make([]*InternalLogEntry, 0)
  362. }
  363. req.executor.LogsByActionId[req.Binding.Action.ID] = append(req.executor.LogsByActionId[req.Binding.Action.ID], req.logEntry)
  364. req.executor.logmutex.Unlock()
  365. log.WithFields(log.Fields{
  366. "actionTitle": req.logEntry.ActionTitle,
  367. "tags": req.Tags,
  368. }).Infof("Action requested")
  369. notifyListenersStarted(req)
  370. return true
  371. }
  372. func stepLogStart(req *ExecutionRequest) bool {
  373. log.WithFields(log.Fields{
  374. "actionTitle": req.logEntry.ActionTitle,
  375. "timeout": req.Binding.Action.Timeout,
  376. }).Infof("Action started")
  377. return true
  378. }
  379. func stepLogFinish(req *ExecutionRequest) bool {
  380. req.logEntry.ExecutionFinished = true
  381. log.WithFields(log.Fields{
  382. "actionTitle": req.logEntry.ActionTitle,
  383. "outputLength": len(req.logEntry.Output),
  384. "timedOut": req.logEntry.TimedOut,
  385. "exit": req.logEntry.ExitCode,
  386. }).Infof("Action finished")
  387. return true
  388. }
  389. func notifyListenersFinished(req *ExecutionRequest) {
  390. for _, listener := range req.executor.listeners {
  391. listener.OnExecutionFinished(req.logEntry)
  392. }
  393. }
  394. func notifyListenersStarted(req *ExecutionRequest) {
  395. for _, listener := range req.executor.listeners {
  396. listener.OnExecutionStarted(req.logEntry)
  397. }
  398. }
  399. func appendErrorToStderr(err error, logEntry *InternalLogEntry) {
  400. if err != nil {
  401. logEntry.Output = err.Error() + "\n\n" + logEntry.Output
  402. }
  403. }
  404. type OutputStreamer struct {
  405. Req *ExecutionRequest
  406. output bytes.Buffer
  407. }
  408. func (ost *OutputStreamer) Write(o []byte) (n int, err error) {
  409. for _, listener := range ost.Req.executor.listeners {
  410. listener.OnOutputChunk(o, ost.Req.TrackingID)
  411. }
  412. return ost.output.Write(o)
  413. }
  414. func (ost *OutputStreamer) String() string {
  415. return ost.output.String()
  416. }
  417. func buildEnv(args map[string]string) []string {
  418. ret := append(os.Environ(), "OLIVETIN=1")
  419. for k, v := range args {
  420. varName := fmt.Sprintf("%v", strings.TrimSpace(strings.ToUpper(k)))
  421. // Skip arguments that might not have a name (eg, confirmation), as this causes weird bugs on Windows.
  422. if varName == "" {
  423. continue
  424. }
  425. ret = append(ret, fmt.Sprintf("%v=%v", varName, v))
  426. }
  427. return ret
  428. }
  429. func stepExec(req *ExecutionRequest) bool {
  430. ctx, cancel := context.WithTimeout(context.Background(), time.Duration(req.Binding.Action.Timeout)*time.Second)
  431. defer cancel()
  432. streamer := &OutputStreamer{Req: req}
  433. cmd := wrapCommandInShell(ctx, req.finalParsedCommand)
  434. cmd.Stdout = streamer
  435. cmd.Stderr = streamer
  436. cmd.Env = buildEnv(req.Arguments)
  437. req.logEntry.ExecutionStarted = true
  438. runerr := cmd.Start()
  439. req.logEntry.Process = cmd.Process
  440. waiterr := cmd.Wait()
  441. req.logEntry.ExitCode = int32(cmd.ProcessState.ExitCode())
  442. req.logEntry.Output = streamer.String()
  443. appendErrorToStderr(runerr, req.logEntry)
  444. appendErrorToStderr(waiterr, req.logEntry)
  445. if ctx.Err() == context.DeadlineExceeded {
  446. log.WithFields(log.Fields{
  447. "actionTitle": req.logEntry.ActionTitle,
  448. }).Warnf("Action timed out")
  449. // The context timeout should kill the process, but let's make sure.
  450. err := req.executor.Kill(req.logEntry)
  451. if err != nil {
  452. log.WithFields(log.Fields{
  453. "actionTitle": req.logEntry.ActionTitle,
  454. }).Warnf("could not kill process: %v", err)
  455. }
  456. req.logEntry.TimedOut = true
  457. req.logEntry.Output += "OliveTin::timeout - this action timed out after " + fmt.Sprintf("%v", req.Binding.Action.Timeout) + " seconds. If you need more time for this action, set a longer timeout. See https://docs.olivetin.app/action_customization/timeouts.html for more help."
  458. }
  459. req.logEntry.DatetimeFinished = time.Now()
  460. return true
  461. }
  462. func stepExecAfter(req *ExecutionRequest) bool {
  463. if req.Binding.Action.ShellAfterCompleted == "" {
  464. return true
  465. }
  466. ctx, cancel := context.WithTimeout(context.Background(), time.Duration(req.Binding.Action.Timeout)*time.Second)
  467. defer cancel()
  468. var stdout bytes.Buffer
  469. var stderr bytes.Buffer
  470. args := map[string]string{
  471. "output": req.logEntry.Output,
  472. "exitCode": fmt.Sprintf("%v", req.logEntry.ExitCode),
  473. "ot_executionTrackingId": req.TrackingID,
  474. "ot_username": req.AuthenticatedUser.Username,
  475. }
  476. finalParsedCommand, err := parseCommandForReplacements(req.Binding.Action.ShellAfterCompleted, args, req.Binding.Entity)
  477. if err != nil {
  478. msg := "Could not prepare shellAfterCompleted command: " + err.Error() + "\n"
  479. req.logEntry.Output += msg
  480. log.Warn(msg)
  481. return true
  482. }
  483. cmd := wrapCommandInShell(ctx, finalParsedCommand)
  484. cmd.Stdout = &stdout
  485. cmd.Stderr = &stderr
  486. cmd.Env = buildEnv(args)
  487. runerr := cmd.Start()
  488. waiterr := cmd.Wait()
  489. req.logEntry.Output += "\n"
  490. req.logEntry.Output += "OliveTin::shellAfterCompleted stdout\n"
  491. req.logEntry.Output += stdout.String()
  492. req.logEntry.Output += "OliveTin::shellAfterCompleted stderr\n"
  493. req.logEntry.Output += stderr.String()
  494. req.logEntry.Output += "OliveTin::shellAfterCompleted errors and summary\n"
  495. appendErrorToStderr(runerr, req.logEntry)
  496. appendErrorToStderr(waiterr, req.logEntry)
  497. if ctx.Err() == context.DeadlineExceeded {
  498. req.logEntry.Output += "Your shellAfterCompleted command timed out."
  499. }
  500. req.logEntry.Output += fmt.Sprintf("Your shellAfterCompleted exited with code %v\n", cmd.ProcessState.ExitCode())
  501. req.logEntry.Output += "OliveTin::shellAfterCompleted output complete\n"
  502. return true
  503. }
  504. //gocyclo:ignore
  505. func stepTrigger(req *ExecutionRequest) bool {
  506. if req.Binding.Action.Triggers == nil {
  507. return true
  508. }
  509. if req.TriggerDepth >= MaxTriggerDepth {
  510. log.WithFields(log.Fields{
  511. "actionTitle": req.logEntry.ActionTitle,
  512. "depth": req.TriggerDepth,
  513. }).Warnf("Trigger action reached maximum depth of %v. Not triggering further actions.", MaxTriggerDepth)
  514. req.logEntry.Output += fmt.Sprintf("OliveTin::trigger - this action reached maximum trigger depth of %v. Not triggering further actions.", MaxTriggerDepth)
  515. return true
  516. }
  517. if len(req.Tags) > 0 && req.Tags[0] == "trigger" {
  518. log.Warnf("Trigger action is triggering another trigger action. This is allowed, but be careful not to create trigger loops.")
  519. }
  520. triggerLoop(req)
  521. return true
  522. }
  523. func triggerLoop(req *ExecutionRequest) {
  524. for _, triggerReq := range req.Binding.Action.Triggers {
  525. binding := req.executor.FindBindingByID(triggerReq)
  526. trigger := &ExecutionRequest{
  527. Binding: binding,
  528. TrackingID: uuid.NewString(),
  529. Tags: []string{"trigger"},
  530. AuthenticatedUser: req.AuthenticatedUser,
  531. Arguments: req.Arguments,
  532. Cfg: req.Cfg,
  533. TriggerDepth: req.TriggerDepth + 1,
  534. }
  535. req.executor.ExecRequest(trigger)
  536. }
  537. }
  538. func stepSaveLog(req *ExecutionRequest) bool {
  539. filename := fmt.Sprintf("%v.%v.%v", req.logEntry.ActionTitle, req.logEntry.DatetimeStarted.Unix(), req.logEntry.ExecutionTrackingID)
  540. saveLogResults(req, filename)
  541. saveLogOutput(req, filename)
  542. return true
  543. }
  544. func firstNonEmpty(one, two string) string {
  545. if one != "" {
  546. return one
  547. }
  548. return two
  549. }
  550. func saveLogResults(req *ExecutionRequest, filename string) {
  551. dir := firstNonEmpty(req.Binding.Action.SaveLogs.ResultsDirectory, req.Cfg.SaveLogs.ResultsDirectory)
  552. if dir != "" {
  553. data, err := yaml.Marshal(req.logEntry)
  554. if err != nil {
  555. log.Warnf("%v", err)
  556. }
  557. filepath := path.Join(dir, filename+".yaml")
  558. err = os.WriteFile(filepath, data, 0644)
  559. if err != nil {
  560. log.Warnf("%v", err)
  561. }
  562. }
  563. }
  564. func saveLogOutput(req *ExecutionRequest, filename string) {
  565. dir := firstNonEmpty(req.Binding.Action.SaveLogs.OutputDirectory, req.Cfg.SaveLogs.OutputDirectory)
  566. if dir != "" {
  567. data := req.logEntry.Output
  568. filepath := path.Join(dir, filename+".log")
  569. err := os.WriteFile(filepath, []byte(data), 0644)
  570. if err != nil {
  571. log.Warnf("%v", err)
  572. }
  573. }
  574. }