mirror of
https://github.com/coder/coder.git
synced 2025-07-15 22:20:27 +00:00
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:
@ -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 {
|
||||
|
@ -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
30
coderd/util/tz/tz.go
Normal 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
|
||||
}
|
50
coderd/util/tz/tz_darwin.go
Normal file
50
coderd/util/tz/tz_darwin.go
Normal 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
|
||||
}
|
44
coderd/util/tz/tz_linux.go
Normal file
44
coderd/util/tz/tz_linux.go
Normal 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
40
coderd/util/tz/tz_test.go
Normal 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)
|
||||
})
|
||||
}
|
55
coderd/util/tz/tz_windows.go
Normal file
55
coderd/util/tz/tz_windows.go
Normal 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
|
||||
}
|
Reference in New Issue
Block a user