cli: streamline autostart ux (#2251)

This commit adds the following changes:

- autostart enable|disable => autostart set|unset
- autostart enable now accepts a more natual schedule format: <time> <days-of-week> <location>
- autostart show now shows configured timezone
- 🎉 automatic timezone detection across mac, windows, linux 🎉

Fixes #1647
This commit is contained in:
Cian Johnston
2022-06-13 22:09:36 +01:00
committed by GitHub
parent 9d155843dd
commit 0a949aaff2
10 changed files with 585 additions and 149 deletions

View File

@ -3,6 +3,7 @@
package schedule
import (
"fmt"
"strings"
"time"
@ -74,7 +75,8 @@ func Weekly(raw string) (*Schedule, error) {
}
// Schedule represents a cron schedule.
// It's essentially a thin wrapper for robfig/cron/v3 that implements Stringer.
// It's essentially a wrapper for robfig/cron/v3 that has additional
// convenience methods.
type Schedule struct {
sched *cron.SpecSchedule
// XXX: there isn't any nice way for robfig/cron to serialize
@ -92,9 +94,9 @@ func (s Schedule) String() string {
return sb.String()
}
// Timezone returns the timezone for the schedule.
func (s Schedule) Timezone() string {
return s.sched.Location.String()
// Location returns the IANA location for the schedule.
func (s Schedule) Location() *time.Location {
return s.sched.Location
}
// Cron returns the cron spec for the schedule with the leading CRON_TZ
@ -137,6 +139,26 @@ func (s Schedule) Min() time.Duration {
return durMin
}
// DaysOfWeek returns a humanized form of the day-of-week field.
func (s Schedule) DaysOfWeek() string {
dow := strings.Fields(s.cronStr)[4]
if dow == "*" {
return "daily"
}
for _, weekday := range []time.Weekday{
time.Sunday,
time.Monday,
time.Tuesday,
time.Wednesday,
time.Thursday,
time.Friday,
time.Saturday,
} {
dow = strings.Replace(dow, fmt.Sprintf("%d", weekday), weekday.String()[:3], 1)
}
return dow
}
// validateWeeklySpec ensures that the day-of-month and month options of
// spec are both set to *
func validateWeeklySpec(spec string) error {

View File

@ -12,59 +12,64 @@ import (
func Test_Weekly(t *testing.T) {
t.Parallel()
testCases := []struct {
name string
spec string
at time.Time
expectedNext time.Time
expectedMin time.Duration
expectedError string
expectedCron string
expectedTz string
expectedString string
name string
spec string
at time.Time
expectedNext time.Time
expectedMin time.Duration
expectedDaysOfWeek string
expectedError string
expectedCron string
expectedLocation *time.Location
expectedString string
}{
{
name: "with timezone",
spec: "CRON_TZ=US/Central 30 9 * * 1-5",
at: time.Date(2022, 4, 1, 14, 29, 0, 0, time.UTC),
expectedNext: time.Date(2022, 4, 1, 14, 30, 0, 0, time.UTC),
expectedMin: 24 * time.Hour,
expectedError: "",
expectedCron: "30 9 * * 1-5",
expectedTz: "US/Central",
expectedString: "CRON_TZ=US/Central 30 9 * * 1-5",
name: "with timezone",
spec: "CRON_TZ=US/Central 30 9 * * 1-5",
at: time.Date(2022, 4, 1, 14, 29, 0, 0, time.UTC),
expectedNext: time.Date(2022, 4, 1, 14, 30, 0, 0, time.UTC),
expectedMin: 24 * time.Hour,
expectedDaysOfWeek: "Mon-Fri",
expectedError: "",
expectedCron: "30 9 * * 1-5",
expectedLocation: mustLocation(t, "US/Central"),
expectedString: "CRON_TZ=US/Central 30 9 * * 1-5",
},
{
name: "without timezone",
spec: "30 9 * * 1-5",
at: time.Date(2022, 4, 1, 9, 29, 0, 0, time.UTC),
expectedNext: time.Date(2022, 4, 1, 9, 30, 0, 0, time.UTC),
expectedMin: 24 * time.Hour,
expectedError: "",
expectedCron: "30 9 * * 1-5",
expectedTz: "UTC",
expectedString: "CRON_TZ=UTC 30 9 * * 1-5",
name: "without timezone",
spec: "30 9 * * 1-5",
at: time.Date(2022, 4, 1, 9, 29, 0, 0, time.UTC),
expectedNext: time.Date(2022, 4, 1, 9, 30, 0, 0, time.UTC),
expectedMin: 24 * time.Hour,
expectedDaysOfWeek: "Mon-Fri",
expectedError: "",
expectedCron: "30 9 * * 1-5",
expectedLocation: time.UTC,
expectedString: "CRON_TZ=UTC 30 9 * * 1-5",
},
{
name: "convoluted with timezone",
spec: "CRON_TZ=US/Central */5 12-18 * * 1,3,6",
at: time.Date(2022, 4, 1, 14, 29, 0, 0, time.UTC),
expectedNext: time.Date(2022, 4, 2, 17, 0, 0, 0, time.UTC), // Apr 1 was a Friday in 2022
expectedMin: 5 * time.Minute,
expectedError: "",
expectedCron: "*/5 12-18 * * 1,3,6",
expectedTz: "US/Central",
expectedString: "CRON_TZ=US/Central */5 12-18 * * 1,3,6",
name: "convoluted with timezone",
spec: "CRON_TZ=US/Central */5 12-18 * * 1,3,6",
at: time.Date(2022, 4, 1, 14, 29, 0, 0, time.UTC),
expectedNext: time.Date(2022, 4, 2, 17, 0, 0, 0, time.UTC), // Apr 1 was a Friday in 2022
expectedMin: 5 * time.Minute,
expectedDaysOfWeek: "Mon,Wed,Sat",
expectedError: "",
expectedCron: "*/5 12-18 * * 1,3,6",
expectedLocation: mustLocation(t, "US/Central"),
expectedString: "CRON_TZ=US/Central */5 12-18 * * 1,3,6",
},
{
name: "another convoluted example",
spec: "CRON_TZ=US/Central 10,20,40-50 * * * *",
at: time.Date(2022, 4, 1, 14, 29, 0, 0, time.UTC),
expectedNext: time.Date(2022, 4, 1, 14, 40, 0, 0, time.UTC),
expectedMin: time.Minute,
expectedError: "",
expectedCron: "10,20,40-50 * * * *",
expectedTz: "US/Central",
expectedString: "CRON_TZ=US/Central 10,20,40-50 * * * *",
name: "another convoluted example",
spec: "CRON_TZ=US/Central 10,20,40-50 * * * *",
at: time.Date(2022, 4, 1, 14, 29, 0, 0, time.UTC),
expectedNext: time.Date(2022, 4, 1, 14, 40, 0, 0, time.UTC),
expectedMin: time.Minute,
expectedDaysOfWeek: "daily",
expectedError: "",
expectedCron: "10,20,40-50 * * * *",
expectedLocation: mustLocation(t, "US/Central"),
expectedString: "CRON_TZ=US/Central 10,20,40-50 * * * *",
},
{
name: "time.Local will bite you",
@ -127,9 +132,10 @@ func Test_Weekly(t *testing.T) {
require.NoError(t, err)
require.Equal(t, testCase.expectedNext, nextTime)
require.Equal(t, testCase.expectedCron, actual.Cron())
require.Equal(t, testCase.expectedTz, actual.Timezone())
require.Equal(t, testCase.expectedLocation, actual.Location())
require.Equal(t, testCase.expectedString, actual.String())
require.Equal(t, testCase.expectedMin, actual.Min())
require.Equal(t, testCase.expectedDaysOfWeek, actual.DaysOfWeek())
} else {
require.EqualError(t, err, testCase.expectedError)
require.Nil(t, actual)
@ -137,3 +143,10 @@ func Test_Weekly(t *testing.T) {
})
}
}
func mustLocation(t *testing.T, s string) *time.Location {
t.Helper()
loc, err := time.LoadLocation(s)
require.NoError(t, err)
return loc
}

30
coderd/util/tz/tz.go Normal file
View File

@ -0,0 +1,30 @@
// Package tz includes utilities for cross-platform timezone/location detection.
package tz
import (
"os"
"time"
"golang.org/x/xerrors"
)
var errNoEnvSet = xerrors.New("no env set")
func locationFromEnv() (*time.Location, error) {
tzEnv, found := os.LookupEnv("TZ")
if !found {
return nil, errNoEnvSet
}
// TZ set but empty means UTC.
if tzEnv == "" {
return time.UTC, nil
}
loc, err := time.LoadLocation(tzEnv)
if err != nil {
return nil, xerrors.Errorf("load location from TZ env: %w", err)
}
return loc, nil
}

View File

@ -0,0 +1,50 @@
//go:build darwin
package tz
import (
"path/filepath"
"strings"
"time"
"golang.org/x/xerrors"
)
const etcLocaltime = "/etc/localtime"
const zoneInfoPath = "/var/db/timezone/zoneinfo/"
// TimezoneIANA attempts to determine the local timezone in IANA format.
// If the TZ environment variable is set, this is used.
// Otherwise, /etc/localtime is used to determine the timezone.
// Reference: https://stackoverflow.com/a/63805394
// On Windows platforms, instead of reading /etc/localtime, powershell
// is used instead to get the current time location in IANA format.
// Reference: https://superuser.com/a/1584968
func TimezoneIANA() (*time.Location, error) {
loc, err := locationFromEnv()
if err == nil {
return loc, nil
}
if !xerrors.Is(err, errNoEnvSet) {
return nil, xerrors.Errorf("lookup timezone from env: %w", err)
}
lp, err := filepath.EvalSymlinks(etcLocaltime)
if err != nil {
return nil, xerrors.Errorf("read location of %s: %w", etcLocaltime, err)
}
// On Darwin, /var/db/timezone/zoneinfo is also a symlink
realZoneInfoPath, err := filepath.EvalSymlinks(zoneInfoPath)
if err != nil {
return nil, xerrors.Errorf("read location of %s: %w", zoneInfoPath, err)
}
stripped := strings.Replace(lp, realZoneInfoPath, "", -1)
stripped = strings.TrimPrefix(stripped, string(filepath.Separator))
loc, err = time.LoadLocation(stripped)
if err != nil {
return nil, xerrors.Errorf("invalid location %q guessed from %s: %w", stripped, lp, err)
}
return loc, nil
}

View File

@ -0,0 +1,44 @@
//go:build linux
package tz
import (
"path/filepath"
"strings"
"time"
"golang.org/x/xerrors"
)
const etcLocaltime = "/etc/localtime"
const zoneInfoPath = "/usr/share/zoneinfo"
// TimezoneIANA attempts to determine the local timezone in IANA format.
// If the TZ environment variable is set, this is used.
// Otherwise, /etc/localtime is used to determine the timezone.
// Reference: https://stackoverflow.com/a/63805394
// On Windows platforms, instead of reading /etc/localtime, powershell
// is used instead to get the current time location in IANA format.
// Reference: https://superuser.com/a/1584968
func TimezoneIANA() (*time.Location, error) {
loc, err := locationFromEnv()
if err == nil {
return loc, nil
}
if !xerrors.Is(err, errNoEnvSet) {
return nil, xerrors.Errorf("lookup timezone from env: %w", err)
}
lp, err := filepath.EvalSymlinks(etcLocaltime)
if err != nil {
return nil, xerrors.Errorf("read location of %s: %w", etcLocaltime, err)
}
stripped := strings.Replace(lp, zoneInfoPath, "", -1)
stripped = strings.TrimPrefix(stripped, string(filepath.Separator))
loc, err = time.LoadLocation(stripped)
if err != nil {
return nil, xerrors.Errorf("invalid location %q guessed from %s: %w", stripped, lp, err)
}
return loc, nil
}

40
coderd/util/tz/tz_test.go Normal file
View File

@ -0,0 +1,40 @@
package tz_test
import (
"os"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/coder/coder/coderd/util/tz"
)
//nolint:paralleltest // Environment variables
func Test_TimezoneIANA(t *testing.T) {
//nolint:paralleltest // t.Setenv
t.Run("Env", func(t *testing.T) {
t.Setenv("TZ", "Europe/Dublin")
zone, err := tz.TimezoneIANA()
assert.NoError(t, err)
if assert.NotNil(t, zone) {
assert.Equal(t, "Europe/Dublin", zone.String())
}
})
//nolint:paralleltest // UnsetEnv
t.Run("NoEnv", func(t *testing.T) {
oldEnv, found := os.LookupEnv("TZ")
if found {
require.NoError(t, os.Unsetenv("TZ"))
t.Cleanup(func() {
_ = os.Setenv("TZ", oldEnv)
})
}
zone, err := tz.TimezoneIANA()
assert.NoError(t, err)
assert.NotNil(t, zone)
})
}

View File

@ -0,0 +1,55 @@
// go:build windows
package tz
import (
"os/exec"
"strings"
"time"
"golang.org/x/xerrors"
)
// cmdTimezone is a Powershell incantation that will return the system
// time location in IANA format.
const cmdTimezone = "[Windows.Globalization.Calendar,Windows.Globalization,ContentType=WindowsRuntime]::New().GetTimeZone()"
// TimezoneIANA attempts to determine the local timezone in IANA format.
// If the TZ environment variable is set, this is used.
// Otherwise, /etc/localtime is used to determine the timezone.
// Reference: https://stackoverflow.com/a/63805394
// On Windows platforms, instead of reading /etc/localtime, powershell
// is used instead to get the current time location in IANA format.
// Reference: https://superuser.com/a/1584968
func TimezoneIANA() (*time.Location, error) {
loc, err := locationFromEnv()
if err == nil {
return loc, nil
}
if !xerrors.Is(err, errNoEnvSet) {
return nil, xerrors.Errorf("lookup timezone from env: %w", err)
}
// https://superuser.com/a/1584968
cmd := exec.Command("powershell.exe", "-NoLogo", "-NoProfile", "-NonInteractive")
// Powershell echoes its stdin so write a newline
cmd.Stdin = strings.NewReader(cmdTimezone + "\n")
outBytes, err := cmd.CombinedOutput()
if err != nil {
return nil, xerrors.Errorf("execute powershell command %q: %w", cmdTimezone, err)
}
outLines := strings.Split(string(outBytes), "\n")
if len(outLines) < 2 {
return nil, xerrors.Errorf("unexpected output from powershell command %q: %q", cmdTimezone, outLines)
}
// What we want is the second line of output
locStr := strings.TrimSpace(outLines[1])
loc, err = time.LoadLocation(locStr)
if err != nil {
return nil, xerrors.Errorf("invalid location %q from powershell: %w", locStr, err)
}
return loc, nil
}