github.com/moby/docker@v26.1.3+incompatible/daemon/logger/journald/internal/fake/sender.go (about)

     1  // Package fake implements a journal writer for testing which is decoupled from
     2  // the system's journald.
     3  //
     4  // The systemd project does not have any facilities to support testing of
     5  // journal reader clients (although it has been requested:
     6  // https://github.com/systemd/systemd/issues/14120) so we have to get creative.
     7  // The systemd-journal-remote command reads serialized journal entries in the
     8  // Journal Export Format and writes them to journal files. This format is
     9  // well-documented and straightforward to generate.
    10  package fake // import "github.com/docker/docker/daemon/logger/journald/internal/fake"
    11  
    12  import (
    13  	"bytes"
    14  	"errors"
    15  	"fmt"
    16  	"os"
    17  	"os/exec"
    18  	"regexp"
    19  	"strconv"
    20  	"testing"
    21  	"time"
    22  
    23  	"code.cloudfoundry.org/clock"
    24  	"github.com/coreos/go-systemd/v22/journal"
    25  	"github.com/google/uuid"
    26  	"gotest.tools/v3/assert"
    27  
    28  	"github.com/docker/docker/daemon/logger/journald/internal/export"
    29  )
    30  
    31  // The systemd-journal-remote command is not conventionally installed on $PATH.
    32  // The manpage from upstream systemd lists the command as
    33  // /usr/lib/systemd/systemd-journal-remote, but Debian installs it to
    34  // /lib/systemd instead.
    35  var cmdPaths = []string{
    36  	"/usr/lib/systemd/systemd-journal-remote",
    37  	"/lib/systemd/systemd-journal-remote",
    38  	"systemd-journal-remote", // Check $PATH anyway, just in case.
    39  }
    40  
    41  // ErrCommandNotFound is returned when the systemd-journal-remote command could
    42  // not be located at the well-known paths or $PATH.
    43  var ErrCommandNotFound = errors.New("systemd-journal-remote command not found")
    44  
    45  // JournalRemoteCmdPath searches for the systemd-journal-remote command in
    46  // well-known paths and the directories named in the $PATH environment variable.
    47  func JournalRemoteCmdPath() (string, error) {
    48  	for _, p := range cmdPaths {
    49  		if path, err := exec.LookPath(p); err == nil {
    50  			return path, nil
    51  		}
    52  	}
    53  	return "", ErrCommandNotFound
    54  }
    55  
    56  // Sender fakes github.com/coreos/go-systemd/v22/journal.Send, writing journal
    57  // entries to an arbitrary journal file without depending on a running journald
    58  // process.
    59  type Sender struct {
    60  	CmdName    string
    61  	OutputPath string
    62  
    63  	// Clock for timestamping sent messages.
    64  	Clock clock.Clock
    65  	// Whether to assign the event's realtime timestamp to the time
    66  	// specified by the SYSLOG_TIMESTAMP variable value. This is roughly
    67  	// analogous to journald receiving the event and assigning it a
    68  	// timestamp in zero time after the SYSLOG_TIMESTAMP value was set,
    69  	// which is higly unrealistic in practice.
    70  	AssignEventTimestampFromSyslogTimestamp bool
    71  	// Boot ID for journal entries. Required by systemd-journal-remote as of
    72  	// https://github.com/systemd/systemd/commit/1eede158519e4e5ed22738c90cb57a91dbecb7f2
    73  	// (systemd 255).
    74  	BootID uuid.UUID
    75  
    76  	// When set, Send will act as a test helper and redirect
    77  	// systemd-journal-remote command output to the test log.
    78  	TB testing.TB
    79  }
    80  
    81  // New constructs a new Sender which will write journal entries to outpath. The
    82  // file name must end in '.journal' and the directory must already exist. The
    83  // journal file will be created if it does not exist. An existing journal file
    84  // will be appended to.
    85  func New(outpath string) (*Sender, error) {
    86  	p, err := JournalRemoteCmdPath()
    87  	if err != nil {
    88  		return nil, err
    89  	}
    90  	sender := &Sender{
    91  		CmdName:    p,
    92  		OutputPath: outpath,
    93  		Clock:      clock.NewClock(),
    94  		BootID:     uuid.New(), // UUIDv4, like systemd itself generates for sd_id128 values.
    95  	}
    96  	return sender, nil
    97  }
    98  
    99  // NewT is like New but will skip the test if the systemd-journal-remote command
   100  // is not available.
   101  func NewT(t *testing.T, outpath string) *Sender {
   102  	t.Helper()
   103  	s, err := New(outpath)
   104  	if errors.Is(err, ErrCommandNotFound) {
   105  		t.Skip(err)
   106  	}
   107  	assert.NilError(t, err)
   108  	s.TB = t
   109  	return s
   110  }
   111  
   112  var validVarName = regexp.MustCompile("^[A-Z0-9][A-Z0-9_]*$")
   113  
   114  // Send is a drop-in replacement for
   115  // github.com/coreos/go-systemd/v22/journal.Send.
   116  func (s *Sender) Send(message string, priority journal.Priority, vars map[string]string) error {
   117  	if s.TB != nil {
   118  		s.TB.Helper()
   119  	}
   120  	var buf bytes.Buffer
   121  	// https://systemd.io/JOURNAL_EXPORT_FORMATS/ says "if you are
   122  	// generating this format you shouldn’t care about these special
   123  	// double-underscore fields," yet systemd-journal-remote treats entries
   124  	// without a __REALTIME_TIMESTAMP as invalid and discards them.
   125  	// Reported upstream: https://github.com/systemd/systemd/issues/22411
   126  	var ts time.Time
   127  	if sts := vars["SYSLOG_TIMESTAMP"]; s.AssignEventTimestampFromSyslogTimestamp && sts != "" {
   128  		var err error
   129  		if ts, err = time.Parse(time.RFC3339Nano, sts); err != nil {
   130  			return fmt.Errorf("fake: error parsing SYSLOG_TIMESTAMP value %q: %w", ts, err)
   131  		}
   132  	} else {
   133  		ts = s.Clock.Now()
   134  	}
   135  	if err := export.WriteField(&buf, "__REALTIME_TIMESTAMP", strconv.FormatInt(ts.UnixMicro(), 10)); err != nil {
   136  		return fmt.Errorf("fake: error writing entry to systemd-journal-remote: %w", err)
   137  	}
   138  	if err := export.WriteField(&buf, "_BOOT_ID", fmt.Sprintf("%x", [16]byte(s.BootID))); err != nil {
   139  		return fmt.Errorf("fake: error writing entry to systemd-journal-remote: %w", err)
   140  	}
   141  	if err := export.WriteField(&buf, "MESSAGE", message); err != nil {
   142  		return fmt.Errorf("fake: error writing entry to systemd-journal-remote: %w", err)
   143  	}
   144  	if err := export.WriteField(&buf, "PRIORITY", strconv.Itoa(int(priority))); err != nil {
   145  		return fmt.Errorf("fake: error writing entry to systemd-journal-remote: %w", err)
   146  	}
   147  	for k, v := range vars {
   148  		if !validVarName.MatchString(k) {
   149  			return fmt.Errorf("fake: invalid journal-entry variable name %q", k)
   150  		}
   151  		if err := export.WriteField(&buf, k, v); err != nil {
   152  			return fmt.Errorf("fake: error writing entry to systemd-journal-remote: %w", err)
   153  		}
   154  	}
   155  	if err := export.WriteEndOfEntry(&buf); err != nil {
   156  		return fmt.Errorf("fake: error writing entry to systemd-journal-remote: %w", err)
   157  	}
   158  
   159  	// Invoke the command separately for each entry to ensure that the entry
   160  	// has been flushed to disk when Send returns.
   161  	cmd := exec.Command(s.CmdName, "--output", s.OutputPath, "-")
   162  	cmd.Stdin = &buf
   163  
   164  	if s.TB != nil {
   165  		out, err := cmd.CombinedOutput()
   166  		s.TB.Logf("[systemd-journal-remote] %s", out)
   167  		var exitErr *exec.ExitError
   168  		if errors.As(err, &exitErr) {
   169  			s.TB.Logf("systemd-journal-remote exit status: %d", exitErr.ExitCode())
   170  		}
   171  		return err
   172  	}
   173  	cmd.Stdout = os.Stdout
   174  	cmd.Stderr = os.Stderr
   175  	return cmd.Run()
   176  }