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
+30
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
}
+50
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
}
+44
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
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)
})
}
+55
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
}