diff --git a/common/buildlogger/build_logger.go b/common/buildlogger/build_logger.go index a1c75e0b..0d9dc7fa 100644 --- a/common/buildlogger/build_logger.go +++ b/common/buildlogger/build_logger.go @@ -96,6 +96,71 @@ func (l *Logger) Stream(streamID int, streamType StreamType) io.WriteCloser { return l.wrap(l.base, streamID, streamType) } +// StepRunnerStream returns a writer for output produced by step-runner. When +// step-runner emits lines already pre-stamped in the runner's timestamper +// format, the data flows straight through to the base trace; otherwise the +// full wrap chain is applied. This is the single hook for any future +// step-runner-specific stream behavior. +func (l *Logger) StepRunnerStream(streamID int, streamType StreamType) io.WriteCloser { + if l.base == nil { + return internal.NewNopCloser(io.Discard) + } + + return &stepRunnerStream{ + passthrough: internal.NewSync(l.base), + buildWrapped: func() io.WriteCloser { + return l.wrap(l.base, streamID, streamType) + }, + } +} + +// stepRunnerStream picks between two writers on its first write. The runner's +// timestamper emits a fixed-width header — YYYY-MM-DDTHH:MM:SS.UUUUUUZ +// — and isPreStamped validates that full shape so producers other than +// step-runner that happen to put 'Z' at byte 26 don't trip the passthrough. +// +// The wrap chain (timestamper, masker, sanitizers, sync) is built lazily via +// buildWrapped so streams that only see pre-stamped output never pay for it. +// step-runner output is the dominant case, so the saving compounds across +// streams. +type stepRunnerStream struct { + once sync.Once + chosen io.WriteCloser + passthrough io.WriteCloser + buildWrapped func() io.WriteCloser +} + +// isPreStamped reports whether p starts with a runner timestamper header +// (YYYY-MM-DDTHH:MM:SS.UUUUUUZ). Validating the full shape, not +// just byte 26, hardens the detection against producers other than +// step-runner that might happen to put 'Z' at byte 26. +func isPreStamped(p []byte) bool { + if len(p) < 28 { + return false + } + return p[4] == '-' && p[7] == '-' && p[10] == 'T' && + p[13] == ':' && p[16] == ':' && p[19] == '.' && + p[26] == 'Z' && p[27] == ' ' +} + +func (s *stepRunnerStream) Write(p []byte) (int, error) { + s.once.Do(func() { + if isPreStamped(p) { + s.chosen = s.passthrough + } else { + s.chosen = s.buildWrapped() + } + }) + return s.chosen.Write(p) +} + +func (s *stepRunnerStream) Close() error { + // Close-without-write: nothing has been buffered by the wrap chain + // (it was never built), so the passthrough's Close is sufficient. + s.once.Do(func() { s.chosen = s.passthrough }) + return s.chosen.Close() +} + // wrap wraps the underlying writer with "filters". Order here somewhat // matters, and the order they're instantiated in is the reverse order in which // writes are processed, e.g. last added filter is the first to process data.