fix(logs): resolve time ranges using client local time zone for logs commands

This commit is contained in:
Pasha Sviderski committed 2026-10-07 13:30:25 +10:00
1 parent e3479409b1
commit 5fa236871f
13 files changed
+282 -16

No files matched your search

+2 -2
View File
@@ -31,8 +31,8 @@ func Flags(options *Options) *pflag.FlagSet {
"Examples:\n"+
" --since 2m30s Relative duration (2 minutes 30 seconds ago)\n"+
" --since 1h Relative duration (1 hour ago)\n"+
" --since 2025-11-24 RFC 3339 date only (midnight using local timezone)\n"+
" --since 2024-05-14T22:50:00 RFC 3339 date/time using local timezone\n"+
" --since 2025-11-24 RFC 3339 date only (midnight using client local timezone)\n"+
" --since 2024-05-14T22:50:00 RFC 3339 date/time using client local timezone\n"+
" --since 2024-01-31T10:30:00Z RFC 3339 date/time in UTC\n"+
" --since 1763953966 Unix timestamp (seconds since January 1, 1970)")
set.StringVarP(&options.Tail, "tail", "n", "100",
+72
View File
@@ -0,0 +1,72 @@
package logs
import (
"fmt"
"strconv"
"strings"
"time"
)
// TimeRange resolves log filters that could be RFC 3339, timestamp, or relative duration to UTC timestamps.
// Dates without a timezone use now's location. Relative durations are computed from now.
func TimeRange(since, until string, now time.Time) (string, string, error) {
var err error
since, err = timestamp(since, now)
if err != nil {
return "", "", fmt.Errorf("invalid --since value: %w", err)
}
until, err = timestamp(until, now)
if err != nil {
return "", "", fmt.Errorf("invalid --until value: %w", err)
}
return since, until, nil
}
func timestamp(value string, now time.Time) (string, error) {
if value == "" {
return "", nil
}
// A bare zero is the Unix epoch, matching Docker's log filters.
if duration, err := time.ParseDuration(value); value != "0" && err == nil {
return now.Add(-duration).UTC().Format(time.RFC3339Nano), nil
}
// Keep Docker's supported date layouts, but use the location's offset at the requested date.
for _, layout := range []string{
time.RFC3339Nano,
"2006-01-02T15:04Z07:00",
"2006-01-02T15Z07:00",
"2006-01-02Z07:00",
"2006-01-02T15:04:05.999999999",
"2006-01-02T15:04",
"2006-01-02T15",
"2006-01-02",
} {
if t, err := time.ParseInLocation(layout, value, now.Location()); err == nil {
return t.UTC().Format(time.RFC3339Nano), nil
}
}
seconds, fraction, hasFraction := strings.Cut(value, ".")
sec, err := strconv.ParseInt(seconds, 10, 64)
if err != nil {
return "", fmt.Errorf("failed to parse '%s' as a time or duration", value)
}
var nsec int64
if hasFraction {
if len(fraction) == 0 || len(fraction) > 9 || strings.ContainsAny(fraction, "+-") {
return "", fmt.Errorf("invalid Unix timestamp fraction in '%s'", value)
}
nsec, err = strconv.ParseInt(fraction+strings.Repeat("0", 9-len(fraction)), 10, 64)
if err != nil {
return "", fmt.Errorf("invalid Unix timestamp fraction in '%s': %w", value, err)
}
}
t := time.Unix(sec, nsec).UTC()
if t.Year() < 0 || t.Year() > 9999 {
return "", fmt.Errorf("Unix timestamp '%s' is outside the RFC 3339 date range", value)
}
return t.Format(time.RFC3339Nano), nil
}
+81
View File
@@ -0,0 +1,81 @@
package logs
import (
"testing"
"time"
timetypes "github.com/docker/docker/api/types/time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestTimeRange(t *testing.T) {
t.Parallel()
location, err := time.LoadLocation("Australia/Sydney")
require.NoError(t, err)
// January is daylight-saving time, but July timestamps must use the winter offset.
now := time.Date(2026, 1, 15, 12, 0, 0, 123456789, location)
tests := []struct {
input string
want string
}{
{"", ""},
{"2026-07-01", "2026-06-30T14:00:00Z"},
{"2026-07-01T10", "2026-07-01T00:00:00Z"},
{"2026-07-01T10:30", "2026-07-01T00:30:00Z"},
{"2026-07-01T10:30:45.123456789", "2026-07-01T00:30:45.123456789Z"},
{"2026-01-01T10:00:00", "2025-12-31T23:00:00Z"},
{"2026-07-01T10:30:45Z", "2026-07-01T10:30:45Z"},
{"2026-07-01T10:30:45+02:00", "2026-07-01T08:30:45Z"},
{"2026-07-01T10:30:45-04:00", "2026-07-01T14:30:45Z"},
{"2026-07-01T10+02:00", "2026-07-01T08:00:00Z"},
{"2026-07-01T10:30+02:00", "2026-07-01T08:30:00Z"},
{"2026-07-01+02:00", "2026-06-30T22:00:00Z"},
{"1763953966", "2025-11-24T03:12:46Z"},
{"1763953966.000000001", "2025-11-24T03:12:46.000000001Z"},
{"0", "1970-01-01T00:00:00Z"},
{"2m30s", "2026-01-15T00:57:30.123456789Z"},
{"-1h", "2026-01-15T02:00:00.123456789Z"},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
since, until, err := TimeRange(tt.input, tt.input, now)
require.NoError(t, err)
assert.Equal(t, tt.want, since)
assert.Equal(t, tt.want, until)
if since != "" {
// The server-side Docker SDK must preserve the cutoff even in another timezone.
actual, err := timetypes.GetTimestamp(since, now.In(time.UTC))
require.NoError(t, err)
expected, err := time.Parse(time.RFC3339Nano, tt.want)
require.NoError(t, err)
sec, nsec, err := timetypes.ParseTimestamps(actual, 0)
require.NoError(t, err)
assert.True(t, expected.Equal(time.Unix(sec, nsec)))
}
})
}
since, until, err := TimeRange("3h", "1h30m", now)
require.NoError(t, err)
assert.Equal(t, "2026-01-14T22:00:00.123456789Z", since)
assert.Equal(t, "2026-01-14T23:30:00.123456789Z", until)
}
func TestTimeRange_Invalid(t *testing.T) {
t.Parallel()
for _, input := range []string{
"invalid", "2026-02-30", "2026-01-01T25:00:00", "1763953966.xyz",
"1763953966.1234567890", "253402300800",
} {
t.Run(input, func(t *testing.T) {
_, _, err := TimeRange(input, "", time.Now())
require.ErrorContains(t, err, "invalid --since value")
_, _, err = TimeRange("", input, time.Now())
require.ErrorContains(t, err, "invalid --until value")
})
}
}