executor.go 18 KB

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