diff --git a/agent/docker.go b/agent/docker.go index 0962c391..a5880f92 100644 --- a/agent/docker.go +++ b/agent/docker.go @@ -1,6 +1,7 @@ package agent import ( + "bufio" "bytes" "context" "encoding/binary" @@ -734,8 +735,17 @@ func (dm *dockerManager) getLogs(ctx context.Context, containerID string) (strin } var builder strings.Builder - multiplexed := resp.Header.Get("Content-Type") == "application/vnd.docker.multiplexed-stream" - if err := decodeDockerLogStream(resp.Body, &builder, multiplexed); err != nil { + contentType := resp.Header.Get("Content-Type") + multiplexed := strings.HasSuffix(contentType, "multiplexed-stream") + logReader := io.Reader(resp.Body) + if !multiplexed { + // Podman may return multiplexed logs without Content-Type. Sniff the first frame header + // with a small buffered reader only when the header check fails. + bufferedReader := bufio.NewReaderSize(resp.Body, 8) + multiplexed = detectDockerMultiplexedStream(bufferedReader) + logReader = bufferedReader + } + if err := decodeDockerLogStream(logReader, &builder, multiplexed); err != nil { return "", err } @@ -747,6 +757,23 @@ func (dm *dockerManager) getLogs(ctx context.Context, containerID string) (strin return logs, nil } +func detectDockerMultiplexedStream(reader *bufio.Reader) bool { + const headerSize = 8 + header, err := reader.Peek(headerSize) + if err != nil { + return false + } + if header[0] != 0x01 && header[0] != 0x02 { + return false + } + // Docker's stream framing header reserves bytes 1-3 as zero. + if header[1] != 0 || header[2] != 0 || header[3] != 0 { + return false + } + frameLen := binary.BigEndian.Uint32(header[4:]) + return frameLen <= maxLogFrameSize +} + func decodeDockerLogStream(reader io.Reader, builder *strings.Builder, multiplexed bool) error { if !multiplexed { _, err := io.Copy(builder, io.LimitReader(reader, maxTotalLogSize)) diff --git a/agent/docker_test.go b/agent/docker_test.go index 28508aeb..d31159f0 100644 --- a/agent/docker_test.go +++ b/agent/docker_test.go @@ -914,6 +914,42 @@ func TestContainerStatsEndToEndWithRealData(t *testing.T) { assert.Equal(t, testTime, testStats.PrevReadTime) } +func TestGetLogsDetectsMultiplexedWithoutContentType(t *testing.T) { + // Docker multiplexed frame: [stream][0,0,0][len(4 bytes BE)][payload] + frame := []byte{ + 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x05, + 'H', 'e', 'l', 'l', 'o', + } + rt := &recordingRoundTripper{ + statusCode: 200, + body: string(frame), + // Intentionally omit content type to simulate Podman behavior. + } + dm := &dockerManager{ + client: &http.Client{Transport: rt}, + } + + logs, err := dm.getLogs(context.Background(), "abcdef123456") + require.NoError(t, err) + assert.Equal(t, "Hello", logs) +} + +func TestGetLogsDoesNotMisclassifyRawStreamAsMultiplexed(t *testing.T) { + // Starts with 0x01, but doesn't match Docker frame signature (reserved bytes aren't all zero). + raw := []byte{0x01, 0x02, 0x03, 0x04, 'r', 'a', 'w'} + rt := &recordingRoundTripper{ + statusCode: 200, + body: string(raw), + } + dm := &dockerManager{ + client: &http.Client{Transport: rt}, + } + + logs, err := dm.getLogs(context.Background(), "abcdef123456") + require.NoError(t, err) + assert.Equal(t, raw, []byte(logs)) +} + func TestEdgeCasesWithRealData(t *testing.T) { // Test with minimal container stats minimalStats := &container.ApiStats{