executor.go 19 KB

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