From 7c7a290cddb7ec62f5fbd10f2b4c61a69b7dbcd0 Mon Sep 17 00:00:00 2001 From: johnstcn Date: Thu, 9 Jun 2022 21:38:46 +0000 Subject: [PATCH 01/13] wip --- cli/autostart.go | 140 ++++++++++++++++++++++++++------- cli/autostart_internal_test.go | 90 +++++++++++++++++++++ cli/autostart_test.go | 45 +++-------- coderd/util/tz/tz_darwin.go | 44 +++++++++++ coderd/util/tz/tz_linux.go | 45 +++++++++++ coderd/util/tz/tz_windows.go | 57 ++++++++++++++ 6 files changed, 359 insertions(+), 62 deletions(-) create mode 100644 cli/autostart_internal_test.go create mode 100644 coderd/util/tz/tz_darwin.go create mode 100644 coderd/util/tz/tz_linux.go create mode 100644 coderd/util/tz/tz_windows.go diff --git a/cli/autostart.go b/cli/autostart.go index 2735b5a42d0cb..83d99bf1dc9f4 100644 --- a/cli/autostart.go +++ b/cli/autostart.go @@ -2,18 +2,27 @@ package cli import ( "fmt" - "os" + "strings" "time" "github.com/spf13/cobra" + "golang.org/x/xerrors" "github.com/coder/coder/coderd/autobuild/schedule" + "github.com/coder/coder/coderd/util/ptr" + "github.com/coder/coder/coderd/util/tz" "github.com/coder/coder/codersdk" ) const autostartDescriptionLong = `To have your workspace build automatically at a regular time you can enable autostart. -When enabling autostart, provide the minute, hour, and day(s) of week. -The default schedule is at 09:00 in your local timezone (TZ env, UTC by default). +When enabling autostart, enter a schedule in the format: [day-of-week] start-time [location]. + * Start-time (required) is accepted either in 12-hour (hh:mm{am|pm}) format, or 24-hour format {hh:mm}. + * Day-of-week (optional) allows specifying in the cron format, e.g. 1,3,5 or Mon-Fri. + Aliases such as @daily are not supported. + Default: * (every day) + * Location (optional) must be a valid location in the IANA timezone database. + If omitted, we will fall back to either the TZ environment variable or /etc/localtime. + You can check your corresponding location by visiting https://ipinfo.io - it shows in the demo widget on the right. ` func autostart() *cobra.Command { @@ -22,12 +31,12 @@ func autostart() *cobra.Command { Use: "autostart enable ", Short: "schedule a workspace to automatically start at a regular time", Long: autostartDescriptionLong, - Example: "coder autostart enable my-workspace --minute 30 --hour 9 --days 1-5 --tz Europe/Dublin", + Example: "coder autostart set my-workspace Mon-Fri 9:30AM Europe/Dublin", } autostartCmd.AddCommand(autostartShow()) - autostartCmd.AddCommand(autostartEnable()) - autostartCmd.AddCommand(autostartDisable()) + autostartCmd.AddCommand(autostartSet()) + autostartCmd.AddCommand(autostartUnset()) return autostartCmd } @@ -75,23 +84,17 @@ func autostartShow() *cobra.Command { return cmd } -func autostartEnable() *cobra.Command { - // yes some of these are technically numbers but the cron library will do that work - var autostartMinute string - var autostartHour string - var autostartDayOfWeek string - var autostartTimezone string +func autostartSet() *cobra.Command { cmd := &cobra.Command{ - Use: "enable ", - Args: cobra.ExactArgs(1), + Use: "set ", + Args: cobra.MinimumNArgs(2), RunE: func(cmd *cobra.Command, args []string) error { client, err := createClient(cmd) if err != nil { return err } - spec := fmt.Sprintf("CRON_TZ=%s %s %s * * %s", autostartTimezone, autostartMinute, autostartHour, autostartDayOfWeek) - validSchedule, err := schedule.Weekly(spec) + sched, err := parseCLISchedule(args[1:]...) if err != nil { return err } @@ -102,32 +105,24 @@ func autostartEnable() *cobra.Command { } err = client.UpdateWorkspaceAutostart(cmd.Context(), workspace.ID, codersdk.UpdateWorkspaceAutostartRequest{ - Schedule: &spec, + Schedule: ptr.Ref(sched.String()), }) if err != nil { return err } - _, _ = fmt.Fprintf(cmd.OutOrStdout(), "\nThe %s workspace will automatically start at %s.\n\n", workspace.Name, validSchedule.Next(time.Now())) + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "\nThe %s workspace will automatically start at %s.\n\n", workspace.Name, sched.Next(time.Now())) return nil }, } - cmd.Flags().StringVar(&autostartMinute, "minute", "0", "autostart minute") - cmd.Flags().StringVar(&autostartHour, "hour", "9", "autostart hour") - cmd.Flags().StringVar(&autostartDayOfWeek, "days", "1-5", "autostart day(s) of week") - tzEnv := os.Getenv("TZ") - if tzEnv == "" { - tzEnv = "UTC" - } - cmd.Flags().StringVar(&autostartTimezone, "tz", tzEnv, "autostart timezone") return cmd } -func autostartDisable() *cobra.Command { +func autostartUnset() *cobra.Command { return &cobra.Command{ - Use: "disable ", + Use: "unset ", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { client, err := createClient(cmd) @@ -153,3 +148,92 @@ func autostartDisable() *cobra.Command { }, } } + +var errInvalidScheduleFormat = xerrors.New("Schedule must be in the format Mon-Fri 09:00AM America/Chicago") +var errInvalidTimeFormat = xerrors.New("Start time must be in the format hh:mm[am|pm] or HH:MM") + +// parseCLISchedule parses a schedule in the format Mon-Fri 09:00AM America/Chicago. +func parseCLISchedule(parts ...string) (*schedule.Schedule, error) { + // If the user was careful and quoted the schedule, un-quote it. + // In the case that only time was specified, this will be a no-op. + if len(parts) == 1 { + parts = strings.Fields(parts[0]) + } + timezone := "" + dayOfWeek := "*" + var hour, minute int + switch len(parts) { + case 1: + t, err := parseTime(parts[0]) + if err != nil { + return nil, err + } + hour, minute = t.Hour(), t.Minute() + case 2: + if !strings.Contains(parts[0], ":") { + // DOW + Time + t, err := parseTime(parts[1]) + if err != nil { + return nil, err + } + hour, minute = t.Hour(), t.Minute() + dayOfWeek = parts[0] + } else { + // Time + TZ + t, err := parseTime(parts[0]) + if err != nil { + return nil, err + } + hour, minute = t.Hour(), t.Minute() + timezone = parts[1] + } + case 3: + // DOW + Time + TZ + t, err := parseTime(parts[1]) + if err != nil { + return nil, err + } + hour, minute = t.Hour(), t.Minute() + dayOfWeek = parts[0] + timezone = parts[2] + default: + return nil, errInvalidScheduleFormat + } + + // If timezone was not specified, attempt to automatically determine it as a last resort. + if timezone == "" { + loc, err := tz.TimezoneIANA() + if err != nil { + return nil, xerrors.Errorf("Could not automatically determine your timezone.") + } + timezone = loc.String() + } + + sched, err := schedule.Weekly(fmt.Sprintf( + "CRON_TZ=%s %d %d * * %s", + timezone, + minute, + hour, + dayOfWeek, + )) + if err != nil { + // This will either be an invalid dayOfWeek or an invalid timezone. + return nil, xerrors.Errorf("Invalid schedule: %w", err) + } + + return sched, nil +} + +func parseTime(s string) (time.Time, error) { + // Assume only time provided, HH:MM[AM|PM] + t, err := time.Parse(time.Kitchen, s) + if err == nil { + return t, nil + } + // Try 24-hour format without AM/PM suffix. + t, err = time.Parse("15:04", s) + if err != nil { + return time.Time{}, errInvalidTimeFormat + } + return t, nil +} diff --git a/cli/autostart_internal_test.go b/cli/autostart_internal_test.go new file mode 100644 index 0000000000000..a564e90b38ed0 --- /dev/null +++ b/cli/autostart_internal_test.go @@ -0,0 +1,90 @@ +package cli + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +//nolint:paralleltest // t.Setenv +func TestParseCLISchedule(t *testing.T) { + for _, testCase := range []struct { + name string + input []string + expectedSchedule string + expectedError string + tzEnv string + }{ + { + name: "DefaultSchedule", + input: []string{"Sun-Sat", "09:00AM", "America/Chicago"}, + expectedSchedule: "CRON_TZ=America/Chicago 0 9 * * Sun-Sat", + tzEnv: "UTC", + }, + { + name: "DefaultSchedule24Hour", + input: []string{"Sun-Sat", "09:00", "America/Chicago"}, + expectedSchedule: "CRON_TZ=America/Chicago 0 9 * * Sun-Sat", + tzEnv: "UTC", + }, + { + name: "TimeOfDayOnly", + input: []string{"09:00AM"}, + expectedSchedule: "CRON_TZ=America/Chicago 0 9 * * *", + tzEnv: "America/Chicago", + }, + { + name: "DayOfWeekAndTime", + input: []string{"Sun-Sat", "09:00AM"}, + expectedSchedule: "CRON_TZ=America/Chicago 0 9 * * Sun-Sat", + tzEnv: "America/Chicago", + }, + { + name: "TimeAndLocation", + input: []string{"09:00AM", "America/Chicago"}, + expectedSchedule: "CRON_TZ=America/Chicago 0 9 * * *", + tzEnv: "UTC", + }, + { + name: "InvalidTime", + input: []string{"9am"}, + expectedError: errInvalidTimeFormat.Error(), + }, + { + name: "DayOfWeekAndInvalidTime", + input: []string{"Sun-Sat", "9am"}, + expectedError: errInvalidTimeFormat.Error(), + }, + { + name: "InvalidTimeAndLocation", + input: []string{"9:", "America/Chicago"}, + expectedError: errInvalidTimeFormat.Error(), + }, + { + name: "DayOfWeekAndInvalidTimeAndLocation", + input: []string{"Sun-Sat", "9am", "America/Chicago"}, + expectedError: errInvalidTimeFormat.Error(), + }, + { + name: "WhoKnows", + input: []string{"Time", "is", "a", "human", "construct"}, + expectedError: errInvalidScheduleFormat.Error(), + }, + } { + testCase := testCase + //nolint:paralleltest // t.Setenv + t.Run(testCase.name, func(t *testing.T) { + t.Setenv("TZ", testCase.tzEnv) + actualSchedule, actualError := parseCLISchedule(testCase.input...) + if testCase.expectedError != "" { + assert.Nil(t, actualSchedule) + assert.ErrorContains(t, actualError, testCase.expectedError) + return + } + assert.NoError(t, actualError) + if assert.NotEmpty(t, actualSchedule) { + assert.Equal(t, testCase.expectedSchedule, actualSchedule.String()) + } + }) + } +} diff --git a/cli/autostart_test.go b/cli/autostart_test.go index fd295fa8e49a7..a19c23d992734 100644 --- a/cli/autostart_test.go +++ b/cli/autostart_test.go @@ -6,7 +6,6 @@ import ( "fmt" "os" "testing" - "time" "github.com/stretchr/testify/require" @@ -50,7 +49,7 @@ func TestAutostart(t *testing.T) { require.Contains(t, stdoutBuf.String(), "schedule: 30 17 * * 1-5") }) - t.Run("EnableDisableOK", func(t *testing.T) { + t.Run("setunsetOK", func(t *testing.T) { t.Parallel() var ( @@ -62,7 +61,7 @@ func TestAutostart(t *testing.T) { project = coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) workspace = coderdtest.CreateWorkspace(t, client, user.OrganizationID, project.ID) tz = "Europe/Dublin" - cmdArgs = []string{"autostart", "enable", workspace.Name, "--minute", "30", "--hour", "9", "--days", "1-5", "--tz", tz} + cmdArgs = []string{"autostart", "set", workspace.Name, "Mon-Fri", "9:30AM", tz} sched = "CRON_TZ=Europe/Dublin 30 9 * * 1-5" stdoutBuf = &bytes.Buffer{} ) @@ -80,8 +79,8 @@ func TestAutostart(t *testing.T) { require.NoError(t, err, "fetch updated workspace") require.Equal(t, sched, *updated.AutostartSchedule, "expected autostart schedule to be set") - // Disable schedule - cmd, root = clitest.New(t, "autostart", "disable", workspace.Name) + // unset schedule + cmd, root = clitest.New(t, "autostart", "unset", workspace.Name) clitest.SetupConfig(t, client, root) cmd.SetOut(stdoutBuf) @@ -95,7 +94,7 @@ func TestAutostart(t *testing.T) { require.Nil(t, updated.AutostartSchedule, "expected autostart schedule to not be set") }) - t.Run("Enable_NotFound", func(t *testing.T) { + t.Run("set_NotFound", func(t *testing.T) { t.Parallel() var ( @@ -105,14 +104,14 @@ func TestAutostart(t *testing.T) { _ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID) ) - cmd, root := clitest.New(t, "autostart", "enable", "doesnotexist") + cmd, root := clitest.New(t, "autostart", "set", "doesnotexist") clitest.SetupConfig(t, client, root) err := cmd.Execute() require.ErrorContains(t, err, "status code 403: Forbidden", "unexpected error") }) - t.Run("Disable_NotFound", func(t *testing.T) { + t.Run("unset_NotFound", func(t *testing.T) { t.Parallel() var ( @@ -122,14 +121,14 @@ func TestAutostart(t *testing.T) { _ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID) ) - cmd, root := clitest.New(t, "autostart", "disable", "doesnotexist") + cmd, root := clitest.New(t, "autostart", "unset", "doesnotexist") clitest.SetupConfig(t, client, root) err := cmd.Execute() require.ErrorContains(t, err, "status code 403: Forbidden", "unexpected error") }) - t.Run("Enable_DefaultSchedule", func(t *testing.T) { + t.Run("set_DefaultSchedule", func(t *testing.T) { t.Parallel() var ( @@ -147,8 +146,8 @@ func TestAutostart(t *testing.T) { if currTz == "" { currTz = "UTC" } - expectedSchedule := fmt.Sprintf("CRON_TZ=%s 0 9 * * 1-5", currTz) - cmd, root := clitest.New(t, "autostart", "enable", workspace.Name) + expectedSchedule := fmt.Sprintf("CRON_TZ=%s 0 9 * * *", currTz) + cmd, root := clitest.New(t, "autostart", "set", workspace.Name, "09:30am") clitest.SetupConfig(t, client, root) err := cmd.Execute() @@ -159,26 +158,4 @@ func TestAutostart(t *testing.T) { require.NoError(t, err, "fetch updated workspace") require.Equal(t, expectedSchedule, *updated.AutostartSchedule, "expected default autostart schedule") }) - - t.Run("BelowTemplateConstraint", func(t *testing.T) { - t.Parallel() - - var ( - client = coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true}) - user = coderdtest.CreateFirstUser(t, client) - version = coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) - _ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID) - project = coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID, func(ctr *codersdk.CreateTemplateRequest) { - ctr.MinAutostartIntervalMillis = ptr.Ref(time.Hour.Milliseconds()) - }) - workspace = coderdtest.CreateWorkspace(t, client, user.OrganizationID, project.ID) - cmdArgs = []string{"autostart", "enable", workspace.Name, "--minute", "*", "--hour", "*"} - ) - - cmd, root := clitest.New(t, cmdArgs...) - clitest.SetupConfig(t, client, root) - - err := cmd.Execute() - require.ErrorContains(t, err, "schedule: Minimum autostart interval 1m0s below template minimum 1h0m0s") - }) } diff --git a/coderd/util/tz/tz_darwin.go b/coderd/util/tz/tz_darwin.go new file mode 100644 index 0000000000000..fcbe3449a041b --- /dev/null +++ b/coderd/util/tz/tz_darwin.go @@ -0,0 +1,44 @@ +//go:build darwin + +package tz + +import ( + "os" + "path/filepath" + "strings" + "time" +) + +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) { + if tzEnv, found := os.LookupEnv("TZ"); found { + if tzEnv == "" { + return time.UTC, nil + } + loc, err := time.LoadLocation(tzEnv) + if err == nil { + return loc, nil + } + } + + lp, err := filepath.EvalSymlinks(etcLocaltime) + if err != nil { + return nil, err + } + + stripped := strings.Replace(lp, etcLocaltime, "", -1) + loc, err := time.LoadLocation(stripped) + if err != nil { + return nil, err + } + return loc +} diff --git a/coderd/util/tz/tz_linux.go b/coderd/util/tz/tz_linux.go new file mode 100644 index 0000000000000..692190c96a2df --- /dev/null +++ b/coderd/util/tz/tz_linux.go @@ -0,0 +1,45 @@ +//go:build linux + +package tz + +import ( + "os" + "path/filepath" + "strings" + "time" +) + +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) { + if tzEnv, found := os.LookupEnv("TZ"); found { + // TZ set but empty means UTC. + if tzEnv == "" { + return time.UTC, nil + } + loc, err := time.LoadLocation(tzEnv) + if err == nil { + return loc, nil + } + } + + lp, err := filepath.EvalSymlinks(etcLocaltime) + if err != nil { + return nil, err + } + + stripped := strings.Replace(lp, etcLocaltime, "", -1) + loc, err := time.LoadLocation(stripped) + if err != nil { + return nil, err + } + return loc, nil +} diff --git a/coderd/util/tz/tz_windows.go b/coderd/util/tz/tz_windows.go new file mode 100644 index 0000000000000..9c1df800aaf3e --- /dev/null +++ b/coderd/util/tz/tz_windows.go @@ -0,0 +1,57 @@ +// go:build windows + +package tz + +import ( + "exec" + "time" +) + +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) { + if tzEnv, found := os.LookupEnv("TZ"); found { + if tzEnv == "" { + return time.UTC, nil + } + loc, err := time.LoadLocation(tzEnv) + if err == nil { + return loc, nil + } + } + + // https://superuser.com/a/1584968 + cmd := exec.Command("powershell", "-nologo", "-noprofile") + stdin, err := cmd.StdinPipe() + if err != nil { + return nil, err + } + + done := make(chan struct{}) + go func() { + defer stdin.Close() + defer close(done) + _, _ = fmt.Fprintln(stdin, cmdTimezone) + } + + <- done + + out, err := cmd.CombinedOutput() + if err != nil { + return nil, err + } + + loc, err := time.LoadLocation(out) + if err != nil { + return nil, err + } + + return loc, nil +} From 84f1a12d6c6de40720db7bac6328d7d98c715c7c Mon Sep 17 00:00:00 2001 From: johnstcn Date: Fri, 10 Jun 2022 11:04:10 +0000 Subject: [PATCH 02/13] fix some unit tests --- cli/autostart_test.go | 2 +- coderd/util/tz/tz_darwin.go | 11 +++++----- coderd/util/tz/tz_linux.go | 15 ++++++++------ coderd/util/tz/tz_test.go | 40 ++++++++++++++++++++++++++++++++++++ coderd/util/tz/tz_windows.go | 27 +++++++++++++++--------- 5 files changed, 73 insertions(+), 22 deletions(-) create mode 100644 coderd/util/tz/tz_test.go diff --git a/cli/autostart_test.go b/cli/autostart_test.go index a19c23d992734..671453bcafb6c 100644 --- a/cli/autostart_test.go +++ b/cli/autostart_test.go @@ -104,7 +104,7 @@ func TestAutostart(t *testing.T) { _ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID) ) - cmd, root := clitest.New(t, "autostart", "set", "doesnotexist") + cmd, root := clitest.New(t, "autostart", "set", "doesnotexist", "09:30AM") clitest.SetupConfig(t, client, root) err := cmd.Execute() diff --git a/coderd/util/tz/tz_darwin.go b/coderd/util/tz/tz_darwin.go index fcbe3449a041b..d123999e39e35 100644 --- a/coderd/util/tz/tz_darwin.go +++ b/coderd/util/tz/tz_darwin.go @@ -10,7 +10,7 @@ import ( ) const etcLocaltime = "/etc/localtime" -const zoneInfoPath = "/var/db/timezone/zoneinfo" +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. @@ -25,20 +25,21 @@ func TimezoneIANA() (*time.Location, error) { return time.UTC, nil } loc, err := time.LoadLocation(tzEnv) - if err == nil { - return loc, nil + if err != nil { + return nil, xerrors.Errorf("load location from TZ env: %w", err) } + return loc, nil } lp, err := filepath.EvalSymlinks(etcLocaltime) if err != nil { - return nil, err + return nil, xerrors.Errorf("read location of %s: %w", etcLocaltime, err) } stripped := strings.Replace(lp, etcLocaltime, "", -1) loc, err := time.LoadLocation(stripped) if err != nil { - return nil, err + return nil, xerrors.Errorf("invalid location %q guessed from %s: %w", stripped, lp, err) } return loc } diff --git a/coderd/util/tz/tz_linux.go b/coderd/util/tz/tz_linux.go index 692190c96a2df..fe67dc88f4738 100644 --- a/coderd/util/tz/tz_linux.go +++ b/coderd/util/tz/tz_linux.go @@ -7,10 +7,12 @@ import ( "path/filepath" "strings" "time" + + "golang.org/x/xerrors" ) const etcLocaltime = "/etc/localtime" -const zoneInfoPath = "/usr/share/zoneinfo" +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. @@ -26,20 +28,21 @@ func TimezoneIANA() (*time.Location, error) { return time.UTC, nil } loc, err := time.LoadLocation(tzEnv) - if err == nil { - return loc, nil + if err != nil { + return nil, xerrors.Errorf("load location from TZ env: %w", err) } + return loc, nil } lp, err := filepath.EvalSymlinks(etcLocaltime) if err != nil { - return nil, err + return nil, xerrors.Errorf("read location of %s: %w", etcLocaltime, err) } - stripped := strings.Replace(lp, etcLocaltime, "", -1) + stripped := strings.Replace(lp, zoneInfoPath, "", -1) loc, err := time.LoadLocation(stripped) if err != nil { - return nil, err + return nil, xerrors.Errorf("invalid location %q guessed from %s: %w", stripped, lp, err) } return loc, nil } diff --git a/coderd/util/tz/tz_test.go b/coderd/util/tz/tz_test.go new file mode 100644 index 0000000000000..35f64843e4782 --- /dev/null +++ b/coderd/util/tz/tz_test.go @@ -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) + }) +} diff --git a/coderd/util/tz/tz_windows.go b/coderd/util/tz/tz_windows.go index 9c1df800aaf3e..8b476557c3dae 100644 --- a/coderd/util/tz/tz_windows.go +++ b/coderd/util/tz/tz_windows.go @@ -3,8 +3,13 @@ package tz import ( - "exec" + "bytes" + "fmt" + "os" + "os/exec" "time" + + "golang.org/x/xerrors" ) const cmdTimezone = "[Windows.Globalization.Calendar,Windows.Globalization,ContentType=WindowsRuntime]::New().GetTimeZone()" @@ -22,16 +27,17 @@ func TimezoneIANA() (*time.Location, error) { return time.UTC, nil } loc, err := time.LoadLocation(tzEnv) - if err == nil { - return loc, nil + if err != nil { + return nil, xerrors.Errorf("load location from TZ env: %w", err) } + return loc, nil } // https://superuser.com/a/1584968 - cmd := exec.Command("powershell", "-nologo", "-noprofile") + cmd := exec.Command("powershell.exe", "-nologo", "-noprofile") stdin, err := cmd.StdinPipe() if err != nil { - return nil, err + return nil, xerrors.Errorf("run powershell: %w", err) } done := make(chan struct{}) @@ -39,18 +45,19 @@ func TimezoneIANA() (*time.Location, error) { defer stdin.Close() defer close(done) _, _ = fmt.Fprintln(stdin, cmdTimezone) - } + }() - <- done + <-done out, err := cmd.CombinedOutput() if err != nil { - return nil, err + return nil, xerrors.Errorf("execute powershell command %q: %w", cmdTimezone, err) } - loc, err := time.LoadLocation(out) + locStr := string(bytes.TrimSpace(out)) + loc, err := time.LoadLocation(locStr) if err != nil { - return nil, err + return nil, xerrors.Errorf("invalid location %q from powershell: %w", locStr, err) } return loc, nil From bad0ac2c1683ff2067c097218662754da5956cff Mon Sep 17 00:00:00 2001 From: johnstcn Date: Fri, 10 Jun 2022 11:09:31 +0000 Subject: [PATCH 03/13] fix more unit tests --- cli/autostart_test.go | 58 +++++++++++++++++++------------------------ 1 file changed, 26 insertions(+), 32 deletions(-) diff --git a/cli/autostart_test.go b/cli/autostart_test.go index 671453bcafb6c..3f6dd5b5ae0a0 100644 --- a/cli/autostart_test.go +++ b/cli/autostart_test.go @@ -4,7 +4,6 @@ import ( "bytes" "context" "fmt" - "os" "testing" "github.com/stretchr/testify/require" @@ -62,7 +61,7 @@ func TestAutostart(t *testing.T) { workspace = coderdtest.CreateWorkspace(t, client, user.OrganizationID, project.ID) tz = "Europe/Dublin" cmdArgs = []string{"autostart", "set", workspace.Name, "Mon-Fri", "9:30AM", tz} - sched = "CRON_TZ=Europe/Dublin 30 9 * * 1-5" + sched = "CRON_TZ=Europe/Dublin 30 9 * * Mon-Fri" stdoutBuf = &bytes.Buffer{} ) @@ -127,35 +126,30 @@ func TestAutostart(t *testing.T) { err := cmd.Execute() require.ErrorContains(t, err, "status code 403: Forbidden", "unexpected error") }) +} - t.Run("set_DefaultSchedule", func(t *testing.T) { - t.Parallel() - - var ( - ctx = context.Background() - client = coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true}) - user = coderdtest.CreateFirstUser(t, client) - version = coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) - _ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID) - project = coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) - workspace = coderdtest.CreateWorkspace(t, client, user.OrganizationID, project.ID) - ) - - // check current TZ env var - currTz := os.Getenv("TZ") - if currTz == "" { - currTz = "UTC" - } - expectedSchedule := fmt.Sprintf("CRON_TZ=%s 0 9 * * *", currTz) - cmd, root := clitest.New(t, "autostart", "set", workspace.Name, "09:30am") - clitest.SetupConfig(t, client, root) - - err := cmd.Execute() - require.NoError(t, err, "unexpected error") - - // Ensure nothing happened - updated, err := client.Workspace(ctx, workspace.ID) - require.NoError(t, err, "fetch updated workspace") - require.Equal(t, expectedSchedule, *updated.AutostartSchedule, "expected default autostart schedule") - }) +//nolint:paralleltest // t.Setenv +func TestAutostartSetDefaultSchedule(t *testing.T) { + t.Setenv("TZ", "UTC") + var ( + ctx = context.Background() + client = coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true}) + user = coderdtest.CreateFirstUser(t, client) + version = coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + _ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID) + project = coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + workspace = coderdtest.CreateWorkspace(t, client, user.OrganizationID, project.ID) + ) + + expectedSchedule := fmt.Sprintf("CRON_TZ=%s 30 9 * * *", "UTC") + cmd, root := clitest.New(t, "autostart", "set", workspace.Name, "9:30AM") + clitest.SetupConfig(t, client, root) + + err := cmd.Execute() + require.NoError(t, err, "unexpected error") + + // Ensure nothing happened + updated, err := client.Workspace(ctx, workspace.ID) + require.NoError(t, err, "fetch updated workspace") + require.Equal(t, expectedSchedule, *updated.AutostartSchedule, "expected default autostart schedule") } From 68187d88c0a717b0e32f75e5908b00535d986c2e Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Fri, 10 Jun 2022 12:19:45 +0100 Subject: [PATCH 04/13] more fixes --- coderd/util/tz/tz_darwin.go | 13 +++++++++++-- coderd/util/tz/tz_linux.go | 3 ++- coderd/util/tz/tz_windows.go | 2 +- 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/coderd/util/tz/tz_darwin.go b/coderd/util/tz/tz_darwin.go index d123999e39e35..9e811add3e114 100644 --- a/coderd/util/tz/tz_darwin.go +++ b/coderd/util/tz/tz_darwin.go @@ -7,6 +7,8 @@ import ( "path/filepath" "strings" "time" + + "golang.org/x/xerrors" ) const etcLocaltime = "/etc/localtime" @@ -36,10 +38,17 @@ func TimezoneIANA() (*time.Location, error) { return nil, xerrors.Errorf("read location of %s: %w", etcLocaltime, err) } - stripped := strings.Replace(lp, etcLocaltime, "", -1) + // 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 + return loc, nil } diff --git a/coderd/util/tz/tz_linux.go b/coderd/util/tz/tz_linux.go index fe67dc88f4738..b1d94c7b82941 100644 --- a/coderd/util/tz/tz_linux.go +++ b/coderd/util/tz/tz_linux.go @@ -12,7 +12,7 @@ import ( ) const etcLocaltime = "/etc/localtime" -const zoneInfoPath = "/usr/share/zoneinfo/" +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. @@ -40,6 +40,7 @@ func TimezoneIANA() (*time.Location, error) { } 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) diff --git a/coderd/util/tz/tz_windows.go b/coderd/util/tz/tz_windows.go index 8b476557c3dae..2d921e1215d68 100644 --- a/coderd/util/tz/tz_windows.go +++ b/coderd/util/tz/tz_windows.go @@ -34,7 +34,7 @@ func TimezoneIANA() (*time.Location, error) { } // https://superuser.com/a/1584968 - cmd := exec.Command("powershell.exe", "-nologo", "-noprofile") + cmd := exec.Command("powershell.exe", "-NoLogo", "-NoProfile", "-NonInteractive") stdin, err := cmd.StdinPipe() if err != nil { return nil, xerrors.Errorf("run powershell: %w", err) From bcb1e8c385785d107e3b0f06dab1d93c7ff9ab33 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Fri, 10 Jun 2022 12:27:27 +0100 Subject: [PATCH 05/13] fix handling of powershell output --- coderd/util/tz/tz_windows.go | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/coderd/util/tz/tz_windows.go b/coderd/util/tz/tz_windows.go index 2d921e1215d68..265037d54f81f 100644 --- a/coderd/util/tz/tz_windows.go +++ b/coderd/util/tz/tz_windows.go @@ -3,15 +3,17 @@ package tz import ( - "bytes" "fmt" "os" "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. @@ -49,12 +51,17 @@ func TimezoneIANA() (*time.Location, error) { <-done - out, err := cmd.CombinedOutput() + outBytes, err := cmd.CombinedOutput() if err != nil { return nil, xerrors.Errorf("execute powershell command %q: %w", cmdTimezone, err) } - locStr := string(bytes.TrimSpace(out)) + 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) From 2556ba2ea783a0efd0ed10644a6010480cd95af3 Mon Sep 17 00:00:00 2001 From: johnstcn Date: Fri, 10 Jun 2022 13:53:31 +0000 Subject: [PATCH 06/13] update CLI output --- cli/autostart.go | 17 ++-- cli/autostart_test.go | 5 +- coderd/autobuild/schedule/schedule.go | 30 +++++- coderd/autobuild/schedule/schedule_test.go | 105 ++++++++++++--------- 4 files changed, 100 insertions(+), 57 deletions(-) diff --git a/cli/autostart.go b/cli/autostart.go index 83d99bf1dc9f4..5e81640ac3067 100644 --- a/cli/autostart.go +++ b/cli/autostart.go @@ -69,13 +69,12 @@ func autostartShow() *cobra.Command { } next := validSchedule.Next(time.Now()) - loc, _ := time.LoadLocation(validSchedule.Timezone()) _, _ = fmt.Fprintf(cmd.OutOrStdout(), "schedule: %s\ntimezone: %s\nnext: %s\n", validSchedule.Cron(), - validSchedule.Timezone(), - next.In(loc), + validSchedule.Location(), + next.In(validSchedule.Location()), ) return nil @@ -111,8 +110,14 @@ func autostartSet() *cobra.Command { return err } - _, _ = fmt.Fprintf(cmd.OutOrStdout(), "\nThe %s workspace will automatically start at %s.\n\n", workspace.Name, sched.Next(time.Now())) - + schedNext := sched.Next(time.Now()) + _, _ = fmt.Fprintf(cmd.OutOrStdout(), + "%s will automatically start %s at %s (%s)\n", + workspace.Name, + sched.DaysOfWeek(), + schedNext.In(sched.Location()).Format(time.Kitchen), + sched.Location().String(), + ) return nil }, } @@ -142,7 +147,7 @@ func autostartUnset() *cobra.Command { return err } - _, _ = fmt.Fprintf(cmd.OutOrStdout(), "\nThe %s workspace will no longer automatically start.\n\n", workspace.Name) + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "%s will no longer automatically start.\n", workspace.Name) return nil }, diff --git a/cli/autostart_test.go b/cli/autostart_test.go index 3f6dd5b5ae0a0..155e08d8583af 100644 --- a/cli/autostart_test.go +++ b/cli/autostart_test.go @@ -71,7 +71,7 @@ func TestAutostart(t *testing.T) { err := cmd.Execute() require.NoError(t, err, "unexpected error") - require.Contains(t, stdoutBuf.String(), "will automatically start at", "unexpected output") + require.Contains(t, stdoutBuf.String(), "will automatically start Mon-Fri at 9:30AM (Europe/Dublin)", "unexpected output") // Ensure autostart schedule updated updated, err := client.Workspace(ctx, workspace.ID) @@ -139,11 +139,13 @@ func TestAutostartSetDefaultSchedule(t *testing.T) { _ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID) project = coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) workspace = coderdtest.CreateWorkspace(t, client, user.OrganizationID, project.ID) + stdoutBuf = &bytes.Buffer{} ) expectedSchedule := fmt.Sprintf("CRON_TZ=%s 30 9 * * *", "UTC") cmd, root := clitest.New(t, "autostart", "set", workspace.Name, "9:30AM") clitest.SetupConfig(t, client, root) + cmd.SetOutput(stdoutBuf) err := cmd.Execute() require.NoError(t, err, "unexpected error") @@ -152,4 +154,5 @@ func TestAutostartSetDefaultSchedule(t *testing.T) { updated, err := client.Workspace(ctx, workspace.ID) require.NoError(t, err, "fetch updated workspace") require.Equal(t, expectedSchedule, *updated.AutostartSchedule, "expected default autostart schedule") + require.Contains(t, stdoutBuf.String(), "will automatically start daily at 9:30AM (UTC)") } diff --git a/coderd/autobuild/schedule/schedule.go b/coderd/autobuild/schedule/schedule.go index de729982c7142..a68759347859a 100644 --- a/coderd/autobuild/schedule/schedule.go +++ b/coderd/autobuild/schedule/schedule.go @@ -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 { diff --git a/coderd/autobuild/schedule/schedule_test.go b/coderd/autobuild/schedule/schedule_test.go index c666b82f0850f..fee40232e9065 100644 --- a/coderd/autobuild/schedule/schedule_test.go +++ b/coderd/autobuild/schedule/schedule_test.go @@ -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 +} From 0884e0ccc43819449d36991714c26be988a992ee Mon Sep 17 00:00:00 2001 From: johnstcn Date: Fri, 10 Jun 2022 15:08:12 +0000 Subject: [PATCH 07/13] make schedule format easier to parse --- cli/autostart.go | 75 ++++++++++++++-------------------- cli/autostart_internal_test.go | 22 ++++++---- cli/autostart_test.go | 6 +-- 3 files changed, 48 insertions(+), 55 deletions(-) diff --git a/cli/autostart.go b/cli/autostart.go index 5e81640ac3067..865cfa6c7803b 100644 --- a/cli/autostart.go +++ b/cli/autostart.go @@ -15,12 +15,12 @@ import ( ) const autostartDescriptionLong = `To have your workspace build automatically at a regular time you can enable autostart. -When enabling autostart, enter a schedule in the format: [day-of-week] start-time [location]. - * Start-time (required) is accepted either in 12-hour (hh:mm{am|pm}) format, or 24-hour format {hh:mm}. +When enabling autostart, enter a schedule in the format: start-time [day-of-week] [timezone]. + * Start-time (required) is accepted either in 12-hour (hh:mm{am|pm}) format, or 24-hour format hh:mm. * Day-of-week (optional) allows specifying in the cron format, e.g. 1,3,5 or Mon-Fri. Aliases such as @daily are not supported. Default: * (every day) - * Location (optional) must be a valid location in the IANA timezone database. + * Timezone (optional) must be a valid location in the IANA timezone database. If omitted, we will fall back to either the TZ environment variable or /etc/localtime. You can check your corresponding location by visiting https://ipinfo.io - it shows in the demo widget on the right. ` @@ -85,8 +85,8 @@ func autostartShow() *cobra.Command { func autostartSet() *cobra.Command { cmd := &cobra.Command{ - Use: "set ", - Args: cobra.MinimumNArgs(2), + Use: "set [day-of-week] [timezone]", + Args: cobra.RangeArgs(2, 4), RunE: func(cmd *cobra.Command, args []string) error { client, err := createClient(cmd) if err != nil { @@ -112,10 +112,10 @@ func autostartSet() *cobra.Command { schedNext := sched.Next(time.Now()) _, _ = fmt.Fprintf(cmd.OutOrStdout(), - "%s will automatically start %s at %s (%s)\n", + "%s will automatically start at %s %s (%s)\n", workspace.Name, - sched.DaysOfWeek(), schedNext.In(sched.Location()).Format(time.Kitchen), + sched.DaysOfWeek(), sched.Location().String(), ) return nil @@ -157,66 +157,53 @@ func autostartUnset() *cobra.Command { var errInvalidScheduleFormat = xerrors.New("Schedule must be in the format Mon-Fri 09:00AM America/Chicago") var errInvalidTimeFormat = xerrors.New("Start time must be in the format hh:mm[am|pm] or HH:MM") -// parseCLISchedule parses a schedule in the format Mon-Fri 09:00AM America/Chicago. +// parseCLISchedule parses a schedule in the format HH:MM{AM|PM} [DOW] [LOCATION] func parseCLISchedule(parts ...string) (*schedule.Schedule, error) { // If the user was careful and quoted the schedule, un-quote it. // In the case that only time was specified, this will be a no-op. if len(parts) == 1 { parts = strings.Fields(parts[0]) } - timezone := "" + var loc *time.Location dayOfWeek := "*" - var hour, minute int + t, err := parseTime(parts[0]) + if err != nil { + return nil, err + } + hour, minute := t.Hour(), t.Minute() + + // Any additional parts get ignored. switch len(parts) { - case 1: - t, err := parseTime(parts[0]) + case 3: + dayOfWeek = parts[1] + loc, err = time.LoadLocation(parts[2]) if err != nil { - return nil, err + return nil, xerrors.Errorf("Invalid timezone %q specified: a valid IANA timezone is required", parts[2]) } - hour, minute = t.Hour(), t.Minute() case 2: - if !strings.Contains(parts[0], ":") { - // DOW + Time - t, err := parseTime(parts[1]) - if err != nil { - return nil, err - } - hour, minute = t.Hour(), t.Minute() - dayOfWeek = parts[0] + // Did they provide day-of-week or location? + if maybeLoc, err := time.LoadLocation(parts[1]); err != nil { + // Assume day-of-week. + dayOfWeek = parts[1] } else { - // Time + TZ - t, err := parseTime(parts[0]) - if err != nil { - return nil, err - } - hour, minute = t.Hour(), t.Minute() - timezone = parts[1] - } - case 3: - // DOW + Time + TZ - t, err := parseTime(parts[1]) - if err != nil { - return nil, err + loc = maybeLoc } - hour, minute = t.Hour(), t.Minute() - dayOfWeek = parts[0] - timezone = parts[2] + case 1: // already handled default: return nil, errInvalidScheduleFormat } - // If timezone was not specified, attempt to automatically determine it as a last resort. - if timezone == "" { - loc, err := tz.TimezoneIANA() + // If location was not specified, attempt to automatically determine it as a last resort. + if loc == nil { + loc, err = tz.TimezoneIANA() if err != nil { - return nil, xerrors.Errorf("Could not automatically determine your timezone.") + return nil, xerrors.Errorf("Could not automatically determine your timezone") } - timezone = loc.String() } sched, err := schedule.Weekly(fmt.Sprintf( "CRON_TZ=%s %d %d * * %s", - timezone, + loc.String(), minute, hour, dayOfWeek, diff --git a/cli/autostart_internal_test.go b/cli/autostart_internal_test.go index a564e90b38ed0..aead1ebb4d8bb 100644 --- a/cli/autostart_internal_test.go +++ b/cli/autostart_internal_test.go @@ -16,14 +16,20 @@ func TestParseCLISchedule(t *testing.T) { tzEnv string }{ { - name: "DefaultSchedule", - input: []string{"Sun-Sat", "09:00AM", "America/Chicago"}, + name: "TimeAndDayOfWeekAndLocation", + input: []string{"09:00AM", "Sun-Sat", "America/Chicago"}, expectedSchedule: "CRON_TZ=America/Chicago 0 9 * * Sun-Sat", tzEnv: "UTC", }, { - name: "DefaultSchedule24Hour", - input: []string{"Sun-Sat", "09:00", "America/Chicago"}, + name: "TimeOfDay24HourAndDayOfWeekAndLocation", + input: []string{"09:00", "Sun-Sat", "America/Chicago"}, + expectedSchedule: "CRON_TZ=America/Chicago 0 9 * * Sun-Sat", + tzEnv: "UTC", + }, + { + name: "TimeOfDay24HourAndDayOfWeekAndLocationButItsAllQuoted", + input: []string{"09:00 Sun-Sat America/Chicago"}, expectedSchedule: "CRON_TZ=America/Chicago 0 9 * * Sun-Sat", tzEnv: "UTC", }, @@ -35,7 +41,7 @@ func TestParseCLISchedule(t *testing.T) { }, { name: "DayOfWeekAndTime", - input: []string{"Sun-Sat", "09:00AM"}, + input: []string{"09:00AM", "Sun-Sat"}, expectedSchedule: "CRON_TZ=America/Chicago 0 9 * * Sun-Sat", tzEnv: "America/Chicago", }, @@ -52,7 +58,7 @@ func TestParseCLISchedule(t *testing.T) { }, { name: "DayOfWeekAndInvalidTime", - input: []string{"Sun-Sat", "9am"}, + input: []string{"9am", "Sun-Sat"}, expectedError: errInvalidTimeFormat.Error(), }, { @@ -62,13 +68,13 @@ func TestParseCLISchedule(t *testing.T) { }, { name: "DayOfWeekAndInvalidTimeAndLocation", - input: []string{"Sun-Sat", "9am", "America/Chicago"}, + input: []string{"9am", "Sun-Sat", "America/Chicago"}, expectedError: errInvalidTimeFormat.Error(), }, { name: "WhoKnows", input: []string{"Time", "is", "a", "human", "construct"}, - expectedError: errInvalidScheduleFormat.Error(), + expectedError: errInvalidTimeFormat.Error(), }, } { testCase := testCase diff --git a/cli/autostart_test.go b/cli/autostart_test.go index 155e08d8583af..5fae0777e52eb 100644 --- a/cli/autostart_test.go +++ b/cli/autostart_test.go @@ -60,7 +60,7 @@ func TestAutostart(t *testing.T) { project = coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) workspace = coderdtest.CreateWorkspace(t, client, user.OrganizationID, project.ID) tz = "Europe/Dublin" - cmdArgs = []string{"autostart", "set", workspace.Name, "Mon-Fri", "9:30AM", tz} + cmdArgs = []string{"autostart", "set", workspace.Name, "9:30AM", "Mon-Fri", tz} sched = "CRON_TZ=Europe/Dublin 30 9 * * Mon-Fri" stdoutBuf = &bytes.Buffer{} ) @@ -71,7 +71,7 @@ func TestAutostart(t *testing.T) { err := cmd.Execute() require.NoError(t, err, "unexpected error") - require.Contains(t, stdoutBuf.String(), "will automatically start Mon-Fri at 9:30AM (Europe/Dublin)", "unexpected output") + require.Contains(t, stdoutBuf.String(), "will automatically start at 9:30AM Mon-Fri (Europe/Dublin)", "unexpected output") // Ensure autostart schedule updated updated, err := client.Workspace(ctx, workspace.ID) @@ -154,5 +154,5 @@ func TestAutostartSetDefaultSchedule(t *testing.T) { updated, err := client.Workspace(ctx, workspace.ID) require.NoError(t, err, "fetch updated workspace") require.Equal(t, expectedSchedule, *updated.AutostartSchedule, "expected default autostart schedule") - require.Contains(t, stdoutBuf.String(), "will automatically start daily at 9:30AM (UTC)") + require.Contains(t, stdoutBuf.String(), "will automatically start at 9:30AM daily (UTC)") } From 377b708e4ad5146ae4c11c1515268a746b67f9dc Mon Sep 17 00:00:00 2001 From: johnstcn Date: Fri, 10 Jun 2022 16:56:28 +0000 Subject: [PATCH 08/13] improve time parsing --- cli/autostart.go | 36 ++++++++++++++++++++++------------ cli/autostart_internal_test.go | 19 ++++++++++++++---- 2 files changed, 38 insertions(+), 17 deletions(-) diff --git a/cli/autostart.go b/cli/autostart.go index 865cfa6c7803b..2627ccaf6cd66 100644 --- a/cli/autostart.go +++ b/cli/autostart.go @@ -15,12 +15,12 @@ import ( ) const autostartDescriptionLong = `To have your workspace build automatically at a regular time you can enable autostart. -When enabling autostart, enter a schedule in the format: start-time [day-of-week] [timezone]. +When enabling autostart, enter a schedule in the format: start-time [day-of-week] [location]. * Start-time (required) is accepted either in 12-hour (hh:mm{am|pm}) format, or 24-hour format hh:mm. * Day-of-week (optional) allows specifying in the cron format, e.g. 1,3,5 or Mon-Fri. Aliases such as @daily are not supported. Default: * (every day) - * Timezone (optional) must be a valid location in the IANA timezone database. + * Location (optional) must be a valid location in the IANA timezone database. If omitted, we will fall back to either the TZ environment variable or /etc/localtime. You can check your corresponding location by visiting https://ipinfo.io - it shows in the demo widget on the right. ` @@ -85,7 +85,7 @@ func autostartShow() *cobra.Command { func autostartSet() *cobra.Command { cmd := &cobra.Command{ - Use: "set [day-of-week] [timezone]", + Use: "set [day-of-week] [location]", Args: cobra.RangeArgs(2, 4), RunE: func(cmd *cobra.Command, args []string) error { client, err := createClient(cmd) @@ -156,6 +156,7 @@ func autostartUnset() *cobra.Command { var errInvalidScheduleFormat = xerrors.New("Schedule must be in the format Mon-Fri 09:00AM America/Chicago") var errInvalidTimeFormat = xerrors.New("Start time must be in the format hh:mm[am|pm] or HH:MM") +var errUnsupportedTimezone = xerrors.New("The location you provided looks like a timezone. Check https://ipinfo.io for your location.") // parseCLISchedule parses a schedule in the format HH:MM{AM|PM} [DOW] [LOCATION] func parseCLISchedule(parts ...string) (*schedule.Schedule, error) { @@ -178,6 +179,10 @@ func parseCLISchedule(parts ...string) (*schedule.Schedule, error) { dayOfWeek = parts[1] loc, err = time.LoadLocation(parts[2]) if err != nil { + _, err = time.Parse("MST", parts[2]) + if err == nil { + return nil, errUnsupportedTimezone + } return nil, xerrors.Errorf("Invalid timezone %q specified: a valid IANA timezone is required", parts[2]) } case 2: @@ -217,15 +222,20 @@ func parseCLISchedule(parts ...string) (*schedule.Schedule, error) { } func parseTime(s string) (time.Time, error) { - // Assume only time provided, HH:MM[AM|PM] - t, err := time.Parse(time.Kitchen, s) - if err == nil { - return t, nil - } - // Try 24-hour format without AM/PM suffix. - t, err = time.Parse("15:04", s) - if err != nil { - return time.Time{}, errInvalidTimeFormat + // Try a number of possible layouts. + for _, layout := range []string{ + time.Kitchen, // 03:04PM + "15:04", + "1504", + "3pm", + "3PM", + "3:04pm", + "3:04PM", + } { + t, err := time.Parse(layout, s) + if err == nil { + return t, nil + } } - return t, nil + return time.Time{}, errInvalidTimeFormat } diff --git a/cli/autostart_internal_test.go b/cli/autostart_internal_test.go index aead1ebb4d8bb..c43d31f5a30e0 100644 --- a/cli/autostart_internal_test.go +++ b/cli/autostart_internal_test.go @@ -39,6 +39,12 @@ func TestParseCLISchedule(t *testing.T) { expectedSchedule: "CRON_TZ=America/Chicago 0 9 * * *", tzEnv: "America/Chicago", }, + { + name: "Time24Military", + input: []string{"0900"}, + expectedSchedule: "CRON_TZ=America/Chicago 0 9 * * *", + tzEnv: "America/Chicago", + }, { name: "DayOfWeekAndTime", input: []string{"09:00AM", "Sun-Sat"}, @@ -53,24 +59,29 @@ func TestParseCLISchedule(t *testing.T) { }, { name: "InvalidTime", - input: []string{"9am"}, + input: []string{"nine"}, expectedError: errInvalidTimeFormat.Error(), }, { name: "DayOfWeekAndInvalidTime", - input: []string{"9am", "Sun-Sat"}, + input: []string{"nine", "Sun-Sat"}, expectedError: errInvalidTimeFormat.Error(), }, { name: "InvalidTimeAndLocation", - input: []string{"9:", "America/Chicago"}, + input: []string{"nine", "America/Chicago"}, expectedError: errInvalidTimeFormat.Error(), }, { name: "DayOfWeekAndInvalidTimeAndLocation", - input: []string{"9am", "Sun-Sat", "America/Chicago"}, + input: []string{"nine", "Sun-Sat", "America/Chicago"}, expectedError: errInvalidTimeFormat.Error(), }, + { + name: "TimezoneProvidedInsteadOfLocation", + input: []string{"09:00AM", "Sun-Sat", "CST"}, + expectedError: errUnsupportedTimezone.Error(), + }, { name: "WhoKnows", input: []string{"Time", "is", "a", "human", "construct"}, From 25ef43750b336b0f41765a7c5f98eb3a73361a84 Mon Sep 17 00:00:00 2001 From: johnstcn Date: Mon, 13 Jun 2022 20:39:19 +0000 Subject: [PATCH 09/13] Address PR comments --- cli/autostart.go | 11 ++++++----- cli/autostart_internal_test.go | 12 ++++++++++++ coderd/util/tz/tz.go | 30 ++++++++++++++++++++++++++++++ coderd/util/tz/tz_darwin.go | 15 ++++++--------- coderd/util/tz/tz_linux.go | 17 ++++++----------- coderd/util/tz/tz_windows.go | 30 ++++++++---------------------- 6 files changed, 68 insertions(+), 47 deletions(-) create mode 100644 coderd/util/tz/tz.go diff --git a/cli/autostart.go b/cli/autostart.go index 2627ccaf6cd66..9351e84e74305 100644 --- a/cli/autostart.go +++ b/cli/autostart.go @@ -15,7 +15,7 @@ import ( ) const autostartDescriptionLong = `To have your workspace build automatically at a regular time you can enable autostart. -When enabling autostart, enter a schedule in the format: start-time [day-of-week] [location]. +When enabling autostart, enter a schedule in the format: [day-of-week] [location]. * Start-time (required) is accepted either in 12-hour (hh:mm{am|pm}) format, or 24-hour format hh:mm. * Day-of-week (optional) allows specifying in the cron format, e.g. 1,3,5 or Mon-Fri. Aliases such as @daily are not supported. @@ -225,12 +225,13 @@ func parseTime(s string) (time.Time, error) { // Try a number of possible layouts. for _, layout := range []string{ time.Kitchen, // 03:04PM + "03:04pm", + "3:04PM", + "3:04pm", "15:04", "1504", - "3pm", - "3PM", - "3:04pm", - "3:04PM", + "03PM", + "03pm", } { t, err := time.Parse(layout, s) if err == nil { diff --git a/cli/autostart_internal_test.go b/cli/autostart_internal_test.go index c43d31f5a30e0..cdbbb9ca6ce26 100644 --- a/cli/autostart_internal_test.go +++ b/cli/autostart_internal_test.go @@ -57,6 +57,18 @@ func TestParseCLISchedule(t *testing.T) { expectedSchedule: "CRON_TZ=America/Chicago 0 9 * * *", tzEnv: "UTC", }, + { + name: "LazyTime", + input: []string{"9am", "America/Chicago"}, + expectedSchedule: "CRON_TZ=America/Chicago 0 9 * * *", + tzEnv: "UTC", + }, + { + name: "ZeroPrefixedLazyTime", + input: []string{"09am", "America/Chicago"}, + expectedSchedule: "CRON_TZ=America/Chicago 0 9 * * *", + tzEnv: "UTC", + }, { name: "InvalidTime", input: []string{"nine"}, diff --git a/coderd/util/tz/tz.go b/coderd/util/tz/tz.go new file mode 100644 index 0000000000000..09687a9993d23 --- /dev/null +++ b/coderd/util/tz/tz.go @@ -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 +} diff --git a/coderd/util/tz/tz_darwin.go b/coderd/util/tz/tz_darwin.go index 9e811add3e114..04306ca146873 100644 --- a/coderd/util/tz/tz_darwin.go +++ b/coderd/util/tz/tz_darwin.go @@ -22,16 +22,13 @@ const zoneInfoPath = "/var/db/timezone/zoneinfo/" // is used instead to get the current time location in IANA format. // Reference: https://superuser.com/a/1584968 func TimezoneIANA() (*time.Location, error) { - if tzEnv, found := os.LookupEnv("TZ"); found { - 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) - } + 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 { @@ -46,7 +43,7 @@ func TimezoneIANA() (*time.Location, error) { stripped := strings.Replace(lp, realZoneInfoPath, "", -1) stripped = strings.TrimPrefix(stripped, string(filepath.Separator)) - loc, err := time.LoadLocation(stripped) + loc, err = time.LoadLocation(stripped) if err != nil { return nil, xerrors.Errorf("invalid location %q guessed from %s: %w", stripped, lp, err) } diff --git a/coderd/util/tz/tz_linux.go b/coderd/util/tz/tz_linux.go index b1d94c7b82941..06d7bfe648a2c 100644 --- a/coderd/util/tz/tz_linux.go +++ b/coderd/util/tz/tz_linux.go @@ -3,7 +3,6 @@ package tz import ( - "os" "path/filepath" "strings" "time" @@ -22,17 +21,13 @@ const zoneInfoPath = "/usr/share/zoneinfo" // is used instead to get the current time location in IANA format. // Reference: https://superuser.com/a/1584968 func TimezoneIANA() (*time.Location, error) { - if tzEnv, found := os.LookupEnv("TZ"); found { - // 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) - } + 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 { @@ -41,7 +36,7 @@ func TimezoneIANA() (*time.Location, error) { stripped := strings.Replace(lp, zoneInfoPath, "", -1) stripped = strings.TrimPrefix(stripped, string(filepath.Separator)) - loc, err := time.LoadLocation(stripped) + loc, err = time.LoadLocation(stripped) if err != nil { return nil, xerrors.Errorf("invalid location %q guessed from %s: %w", stripped, lp, err) } diff --git a/coderd/util/tz/tz_windows.go b/coderd/util/tz/tz_windows.go index 265037d54f81f..9500754c63756 100644 --- a/coderd/util/tz/tz_windows.go +++ b/coderd/util/tz/tz_windows.go @@ -24,32 +24,18 @@ const cmdTimezone = "[Windows.Globalization.Calendar,Windows.Globalization,Conte // is used instead to get the current time location in IANA format. // Reference: https://superuser.com/a/1584968 func TimezoneIANA() (*time.Location, error) { - if tzEnv, found := os.LookupEnv("TZ"); found { - 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) - } + 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") - stdin, err := cmd.StdinPipe() - if err != nil { - return nil, xerrors.Errorf("run powershell: %w", err) - } - - done := make(chan struct{}) - go func() { - defer stdin.Close() - defer close(done) - _, _ = fmt.Fprintln(stdin, cmdTimezone) - }() - - <-done + // Powershell echoes its stdin so write a newline + cmd.Stdin = strings.NewReader(cmdTimezone + "\n") outBytes, err := cmd.CombinedOutput() if err != nil { @@ -62,7 +48,7 @@ func TimezoneIANA() (*time.Location, error) { } // What we want is the second line of output locStr := strings.TrimSpace(outLines[1]) - loc, err := time.LoadLocation(locStr) + loc, err = time.LoadLocation(locStr) if err != nil { return nil, xerrors.Errorf("invalid location %q from powershell: %w", locStr, err) } From 4bab79fda0a80bf094d04c834715226e178f2f92 Mon Sep 17 00:00:00 2001 From: johnstcn Date: Mon, 13 Jun 2022 20:43:19 +0000 Subject: [PATCH 10/13] fixup! Address PR comments --- cli/autostart.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cli/autostart.go b/cli/autostart.go index 9351e84e74305..2315aced53d06 100644 --- a/cli/autostart.go +++ b/cli/autostart.go @@ -28,10 +28,10 @@ When enabling autostart, enter a schedule in the format: [day-of-we func autostart() *cobra.Command { autostartCmd := &cobra.Command{ Annotations: workspaceCommand, - Use: "autostart enable ", + Use: "autostart set [day-of-week] [location]", Short: "schedule a workspace to automatically start at a regular time", Long: autostartDescriptionLong, - Example: "coder autostart set my-workspace Mon-Fri 9:30AM Europe/Dublin", + Example: "coder autostart set my-workspace 9:30AM Mon-Fri Europe/Dublin", } autostartCmd.AddCommand(autostartShow()) From e6921a7a7bce77b1f89955d140088c970eea7a05 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Mon, 13 Jun 2022 21:48:00 +0100 Subject: [PATCH 11/13] a pox upon you darwin --- coderd/util/tz/tz_darwin.go | 1 - 1 file changed, 1 deletion(-) diff --git a/coderd/util/tz/tz_darwin.go b/coderd/util/tz/tz_darwin.go index 04306ca146873..88ed56f785e36 100644 --- a/coderd/util/tz/tz_darwin.go +++ b/coderd/util/tz/tz_darwin.go @@ -3,7 +3,6 @@ package tz import ( - "os" "path/filepath" "strings" "time" From 9e76791dec76af33f423a3e0216d89244736a121 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Mon, 13 Jun 2022 21:49:16 +0100 Subject: [PATCH 12/13] remove unused import on windows --- coderd/util/tz/tz_windows.go | 2 -- 1 file changed, 2 deletions(-) diff --git a/coderd/util/tz/tz_windows.go b/coderd/util/tz/tz_windows.go index 9500754c63756..0aedca5f773f2 100644 --- a/coderd/util/tz/tz_windows.go +++ b/coderd/util/tz/tz_windows.go @@ -3,8 +3,6 @@ package tz import ( - "fmt" - "os" "os/exec" "strings" "time" From 6a82f77616a9114fb23a8a8c1a87a275559fca49 Mon Sep 17 00:00:00 2001 From: johnstcn Date: Mon, 13 Jun 2022 20:56:06 +0000 Subject: [PATCH 13/13] add missing time formats --- cli/autostart.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/cli/autostart.go b/cli/autostart.go index 2315aced53d06..dd413a0498f3a 100644 --- a/cli/autostart.go +++ b/cli/autostart.go @@ -232,6 +232,8 @@ func parseTime(s string) (time.Time, error) { "1504", "03PM", "03pm", + "3PM", + "3pm", } { t, err := time.Parse(layout, s) if err == nil {