snapshotting: ability to specify timestamp location != UTC (#801)

This PR adds a new field optional field `timestamp_location` that allows
the user to specify a timezone different than the default UTC for use in
the snapshot suffix.

I took @mjasnik 's PR https://github.com/zrepl/zrepl/pull/785 and
refactored+extended it as follows:
* move all formatting logic into its own package
* disallow `dense` and `human` with formats != UTC to protect users from
stupidity
* document behavior more clearly
* regression test for existing users
This commit is contained in:
Christian Schwarz
2024-10-18 15:12:41 +02:00
committed by GitHub
parent 904c1512a3
commit b9b9ad10cf
11 changed files with 322 additions and 66 deletions
+52
View File
@@ -0,0 +1,52 @@
package snapname
import (
"fmt"
"time"
"github.com/pkg/errors"
"github.com/zrepl/zrepl/daemon/snapper/snapname/timestamp"
"github.com/zrepl/zrepl/zfs"
)
type Formatter struct {
prefix string
timestamp *timestamp.Formatter
}
func New(prefix, tsFormat, tsLocation string) (*Formatter, error) {
timestamp, err := timestamp.New(tsFormat, tsLocation)
if err != nil {
return nil, errors.Wrap(err, "build timestamp formatter")
}
formatter := &Formatter{
prefix: prefix,
timestamp: timestamp,
}
// Best-effort check to detect whether the result would be an invalid name.
// Test two dates that in most places have will have different time zone offsets due to DST.
check := func(t time.Time) error {
testFormat := formatter.Format(t)
if err := zfs.ComponentNamecheck(testFormat); err != nil {
// testFormat last, can be quite long
return fmt.Errorf("`invalid snapshot name would result from `prefix+$timestamp`: %s: %q", err, testFormat)
}
return nil
}
if err := check(time.Date(2020, 6, 1, 0, 0, 0, 0, time.UTC)); err != nil {
return nil, err
}
if err := check(time.Date(2020, 12, 1, 0, 0, 0, 0, time.UTC)); err != nil {
return nil, err
}
return formatter, nil
}
func (f *Formatter) Format(now time.Time) string {
return f.prefix + f.timestamp.Format(now)
}
func (f *Formatter) Prefix() string {
return f.prefix
}
@@ -0,0 +1,93 @@
package timestamp
import (
"fmt"
"strconv"
"strings"
"time"
"github.com/pkg/errors"
)
type Formatter struct {
format func(time.Time) string
location *time.Location
}
func New(formatString string, locationString string) (*Formatter, error) {
location, err := time.LoadLocation(locationString) // no shadow
if err != nil {
return nil, errors.Wrapf(err, "load location from string %q", locationString)
}
makeFormatFunc := func(formatString string) (func(time.Time) string, error) {
// NB: we use zfs.EntityNamecheck in higher-level code to filter out all invalid characters.
// This check here is specifically so that we know for sure that the `+`=>`_` replacement
// that we do in the returned func replaces exactly the timezone offset `+` and not some other `+`.
if strings.Contains(formatString, "+") {
return nil, fmt.Errorf("character '+' is not allowed in ZFS snapshot names and has special handling")
}
return func(t time.Time) string {
res := t.Format(formatString)
// if the formatString contains a time zone specifier
// and the location would result in a positive offset to UTC
// then the result of t.Format would contain a '+' sign.
if isLocationPositiveOffsetToUTC(location) {
// the only source of `+` can be the positive time zone offset because we disallowed `+` as a character in the format string
res = strings.Replace(res, "+", "_", 1)
}
if strings.Contains(res, "+") {
panic(fmt.Sprintf("format produced a string containing illegal character '+' that wasn't the expected case of positive time zone offset: format=%q location=%q unix=%q result=%q", formatString, location, t.Unix(), res))
}
return res
}, nil
}
var formatFunc func(time.Time) string
mustUseUtcError := func() error {
return fmt.Errorf("format string requires UTC location")
}
switch strings.ToLower(formatString) {
case "dense":
if location != time.UTC {
err = mustUseUtcError()
} else {
formatFunc, err = makeFormatFunc("20060102_150405_000")
}
case "human":
if location != time.UTC {
err = mustUseUtcError()
} else {
formatFunc, err = makeFormatFunc("2006-01-02_15:04:05")
}
case "iso-8601":
formatFunc, err = makeFormatFunc("2006-01-02T15:04:05.000Z0700")
case "unix-seconds":
if location != time.UTC {
// Technically not required because unix time is by definition in UTC
// but let's make that clear to confused users...
err = mustUseUtcError()
} else {
formatFunc = func(t time.Time) string {
return strconv.FormatInt(t.Unix(), 10)
}
}
default:
formatFunc, err = makeFormatFunc(formatString)
}
if err != nil {
return nil, errors.Wrapf(err, "invalid format string %q or location %q", formatString, locationString)
}
return &Formatter{
format: formatFunc,
location: location,
}, nil
}
func isLocationPositiveOffsetToUTC(location *time.Location) bool {
_, offsetSeconds := time.Now().In(location).Zone()
return offsetSeconds > 0
}
func (f *Formatter) Format(t time.Time) string {
t = t.In(f.location)
return f.format(t)
}
@@ -0,0 +1,84 @@
package timestamp
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
var utc, _ = time.LoadLocation("UTC")
var berlin, _ = time.LoadLocation("Europe/Berlin")
var nyc, _ = time.LoadLocation("America/New_York")
func TestAssumptionsAboutTimePackage(t *testing.T) {
now := time.Now()
assert.Equal(t, now.In(utc).Unix(), now.In(berlin).Unix(), "unix timestamp is always in UTC")
}
func TestLegacyIso8601Format(t *testing.T) {
// Before we allowed users to specify the location of the time to be formatted,
// we always used UTC.
// At the time, the `iso-8601` format used the following format string
// "2006-01-02T15:04:05.000Z"
// That format string's `Z` was never identified by the Go time package as a time zone specifier.
// The correct way would have been
// "2006-01-02T15:04:05.000Z07"
// "2006-01-02T15:04:05.000Z0700"
// "2006-01-02T15:04:05.000Z070000"
// or any variation of how the minute / second offsets are displayed.
// However, because of the forced location UTC, it didn't matter, because both format strings
// evaluate to the same time string.
//
// This test is here to ensure that the legacy behavior is preserved for users who don't specify a non-UTC location
// (UTC location is the default location at this time)
oldFormatString := "2006-01-02T15:04:05.000Z"
now := time.Now()
oldOutput := now.In(utc).Format(oldFormatString)
currentImpl, err := New("iso-8601", "UTC")
require.NoError(t, err)
currentOutput := currentImpl.Format(now)
assert.Equal(t, oldOutput, currentOutput, "legacy behavior of iso-8601 format is preserved")
}
func TestIso8601PrintsTimeZoneOffset(t *testing.T) {
f, err := New("iso-8601", "Europe/Berlin")
require.NoError(t, err)
out := f.Format(time.Date(2024, 5, 14, 21, 16, 23, 0, time.UTC))
require.Equal(t, "2024-05-14T23:16:23.000_0200", out, "time zone offset is printed")
}
func TestBuiltinFormatsErrorOnNonUtcLocation(t *testing.T) {
var err error
expectMsg := `^.*: format string requires UTC location$`
_, err = New("dense", "Europe/Berlin")
require.Regexp(t, expectMsg, err)
_, err = New("human", "Europe/Berlin")
require.Regexp(t, expectMsg, err)
_, err = New("unix-seconds", "Europe/Berlin")
require.Regexp(t, expectMsg, err)
_, err = New("iso-8601", "Europe/Berlin")
require.NoError(t, err, "iso-8601 prints time zone, so non-UTC locations are allowed, see test TestIso8601PrintsTimeZoneOffset")
}
func TestPositiveUtcOffsetDetection(t *testing.T) {
assert.True(t, isLocationPositiveOffsetToUTC(berlin), "Berlin is UTC+1/UTC+2")
assert.False(t, isLocationPositiveOffsetToUTC(utc), "UTC is UTC+0")
assert.False(t, isLocationPositiveOffsetToUTC(nyc), "New York is UTC-5/UTC-4")
}
func TestFormatCanReplacePlusWithUnderscore(t *testing.T) {
f, err := New("2006-01-02_15:04:05-07:00:00", "Europe/Berlin")
require.NoError(t, err)
out := f.Format(time.Date(2024, 5, 14, 21, 16, 23, 0, time.UTC))
require.Equal(t, "2024-05-14_23:16:23_02:00:00", out, "+ is replaced with _ so we can use the string in ZFS snapshots")
}