Sybren A. Stüvel 17b664f152 Worker: log copy-pastable commandline invocation
Log any CLI command that's run in a way that can be easily copy-pasted
from the task log. This can help a lot in determining whether an issue
is caused by Flamenco or by the CLI program itself.
2023-12-25 15:07:18 +01:00

174 lines
5.1 KiB
Go

package cli_runner
// SPDX-License-Identifier: GPL-3.0-or-later
import (
"bufio"
"context"
"fmt"
"io"
"os/exec"
"github.com/alessio/shellescape"
"github.com/rs/zerolog"
)
// The buffer size used to read stdout/stderr output from subprocesses, in
// bytes. Effectively this determines the maximum line length that can be
// handled in one go. Lines that are longer will be broken up.
const StdoutBufferSize = 40 * 1024
// CLIRunner is a wrapper around exec.CommandContext() to allow mocking.
type CLIRunner struct {
}
func NewCLIRunner() *CLIRunner {
return &CLIRunner{}
}
func (cli *CLIRunner) CommandContext(ctx context.Context, name string, arg ...string) *exec.Cmd {
return exec.CommandContext(ctx, name, arg...)
}
// RunWithTextOutput runs a command and sends its output line-by-line to the
// lineChannel. Stdout and stderr are combined.
// Before returning. RunWithTextOutput() waits for the subprocess, to ensure it
// doesn't become defunct.
//
// Note that all output read from the command is logged via `logChunker` as
// well, so the receiving end of the `lineChannel` does not have to do this.
func (cli *CLIRunner) RunWithTextOutput(
ctx context.Context,
logger zerolog.Logger,
execCmd *exec.Cmd,
logChunker LogChunker,
lineChannel chan<- string,
) error {
outPipe, err := execCmd.StdoutPipe()
if err != nil {
return err
}
execCmd.Stderr = execCmd.Stdout // Redirect stderr to stdout.
if err := cli.logCmd(ctx, logger, execCmd, logChunker); err != nil {
logger.Error().Err(err).Msg("error logging CLI execution")
return err
}
if err := execCmd.Start(); err != nil {
logger.Error().Err(err).Msg("error starting CLI execution")
return err
}
subprocPID := execCmd.Process.Pid
logger = logger.With().Int("pid", subprocPID).Logger()
reader := bufio.NewReaderSize(outPipe, StdoutBufferSize)
// returnErr determines which error is returned to the caller. More important
// errors overwrite less important ones. This is done via a variable instead
// of simply returning, because the function must be run to completion in
// order to wait for processes (and not create defunct ones).
var returnErr error = nil
// If a line longer than our buffer is received, it will be trimmed to the
// bufffer length. This means that it may not end on a valid character
// boundary. Any leftover bytes are collected here, and prepended to the next
// line.
leftovers := []byte{}
readloop:
for {
lineBytes, isPrefix, readErr := reader.ReadLine()
switch {
case readErr == io.EOF:
break readloop
case readErr != nil:
logger.Error().Err(err).Msg("error reading stdout/err")
returnErr = readErr
break readloop
}
// Prepend any leftovers from the previous line to the received bytes.
if len(leftovers) > 0 {
lineBytes = append(leftovers, lineBytes...)
leftovers = []byte{}
}
// Make sure long lines are broken on character boundaries.
lineBytes, leftovers = splitOnCharacterBoundary(lineBytes)
line := string(lineBytes)
if isPrefix {
prefix := []rune(line)
if len(prefix) > 256 {
prefix = prefix[:256]
}
logger.Warn().
Str("line", fmt.Sprintf("%s...", string(prefix))).
Int("bytesRead", len(lineBytes)).
Msg("unexpectedly long line read, will be split up")
}
logger.Debug().Msg(line)
if lineChannel != nil {
lineChannel <- line
}
if err := logChunker.Append(ctx, fmt.Sprintf("pid=%d > %s", subprocPID, line)); err != nil {
returnErr = fmt.Errorf("appending log entry to log chunker: %w", err)
break readloop
}
}
if err := logChunker.Flush(ctx); err != nil {
// any readErr is less important, as these are likely caused by other
// issues, which will surface on the Wait() and Success() calls.
returnErr = fmt.Errorf("flushing log chunker: %w", err)
}
if err := execCmd.Wait(); err != nil {
logger.Error().
Int("exitCode", execCmd.ProcessState.ExitCode()).
Msg("command exited abnormally")
returnErr = fmt.Errorf("command exited abnormally with code %d", execCmd.ProcessState.ExitCode())
}
if returnErr != nil {
logger.Error().Err(err).
Int("exitCode", execCmd.ProcessState.ExitCode()).
Msg("command exited abnormally")
return returnErr
}
logger.Info().Msg("command exited succesfully")
return nil
}
// Log the command before starting, in a way that can be easily copy-pasted
// from the task log to a shell.
func (cli *CLIRunner) logCmd(
ctx context.Context,
logger zerolog.Logger,
execCmd *exec.Cmd,
logChunker LogChunker,
) error {
quotedCommand := shellescape.QuoteCommand(execCmd.Args)
// The quotedCommand is intentionally not logged in one of the structured
// fields here, as those will be quoted once again and hard to read /
// copy-paste from. And that's the whole point of doing this quoting, to get
// something copy-pastable for debugging purposes (mostly to answer: is the
// issue with Blender or with Flamenco?)
logger.Info().Msgf("going to run: %s", quotedCommand)
logger.Debug().Strs("args", execCmd.Args).Msg("CLI arguments")
forTaskLog := fmt.Sprintf("going to run:\n\n %s\n", quotedCommand)
err := logChunker.Append(ctx, forTaskLog)
if err != nil {
return fmt.Errorf("could not send to Manager's task log: %w", err)
}
return nil
}