executor.go 18 KB

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