diff --git a/cli/workspaceautostart.go b/cli/workspaceautostart.go index a5954e925168b..b55340f0d3843 100644 --- a/cli/workspaceautostart.go +++ b/cli/workspaceautostart.go @@ -2,6 +2,7 @@ package cli import ( "fmt" + "os" "time" "github.com/spf13/cobra" @@ -11,20 +12,16 @@ import ( ) const autostartDescriptionLong = `To have your workspace build automatically at a regular time you can enable autostart. -When enabling autostart, provide a schedule. This schedule is in cron format except only -the following fields are allowed: -- minute -- hour -- day of week - -For example, to start your workspace every weekday at 9.30 am, provide the schedule '30 9 1-5'.` +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). +` func workspaceAutostart() *cobra.Command { autostartCmd := &cobra.Command{ - Use: "autostart enable ", + Use: "autostart enable ", Short: "schedule a workspace to automatically start at a regular time", Long: autostartDescriptionLong, - Example: "coder workspaces autostart enable my-workspace '30 9 1-5'", + Example: "coder workspaces autostart enable my-workspace --minute 30 --hour 9 --days 1-5 --tz Europe/Dublin", Hidden: true, // TODO(cian): un-hide when autostart scheduling implemented } @@ -35,22 +32,28 @@ func workspaceAutostart() *cobra.Command { } func workspaceAutostartEnable() *cobra.Command { - return &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 + cmd := &cobra.Command{ Use: "enable ", ValidArgsFunction: validArgsWorkspaceName, - Args: cobra.ExactArgs(2), + Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { client, err := createClient(cmd) if err != nil { return err } - workspace, err := client.WorkspaceByName(cmd.Context(), codersdk.Me, args[0]) + spec := fmt.Sprintf("CRON_TZ=%s %s %s * * %s", autostartTimezone, autostartMinute, autostartHour, autostartDayOfWeek) + validSchedule, err := schedule.Weekly(spec) if err != nil { return err } - validSchedule, err := schedule.Weekly(args[1]) + workspace, err := client.WorkspaceByName(cmd.Context(), codersdk.Me, args[0]) if err != nil { return err } @@ -67,6 +70,16 @@ func workspaceAutostartEnable() *cobra.Command { 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 workspaceAutostartDisable() *cobra.Command { diff --git a/cli/workspaceautostart_test.go b/cli/workspaceautostart_test.go index 760def044d166..5772e1450b67c 100644 --- a/cli/workspaceautostart_test.go +++ b/cli/workspaceautostart_test.go @@ -3,6 +3,8 @@ package cli_test import ( "bytes" "context" + "fmt" + "os" "testing" "github.com/stretchr/testify/require" @@ -27,11 +29,13 @@ func TestWorkspaceAutostart(t *testing.T) { _ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID) project = coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) workspace = coderdtest.CreateWorkspace(t, client, codersdk.Me, project.ID) - sched = "CRON_TZ=Europe/Dublin 30 9 1-5" + tz = "Europe/Dublin" + cmdArgs = []string{"workspaces", "autostart", "enable", workspace.Name, "--minute", "30", "--hour", "9", "--days", "1-5", "--tz", tz} + sched = "CRON_TZ=Europe/Dublin 30 9 * * 1-5" stdoutBuf = &bytes.Buffer{} ) - cmd, root := clitest.New(t, "workspaces", "autostart", "enable", workspace.Name, sched) + cmd, root := clitest.New(t, cmdArgs...) clitest.SetupConfig(t, client, root) cmd.SetOut(stdoutBuf) @@ -68,10 +72,9 @@ func TestWorkspaceAutostart(t *testing.T) { user = coderdtest.CreateFirstUser(t, client) version = coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) _ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID) - sched = "CRON_TZ=Europe/Dublin 30 9 1-5" ) - cmd, root := clitest.New(t, "workspaces", "autostart", "enable", "doesnotexist", sched) + cmd, root := clitest.New(t, "workspaces", "autostart", "enable", "doesnotexist") clitest.SetupConfig(t, client, root) err := cmd.Execute() @@ -96,34 +99,7 @@ func TestWorkspaceAutostart(t *testing.T) { require.ErrorContains(t, err, "status code 404: no workspace found by name", "unexpected error") }) - t.Run("Enable_InvalidSchedule", func(t *testing.T) { - t.Parallel() - - var ( - ctx = context.Background() - client = coderdtest.New(t, nil) - _ = coderdtest.NewProvisionerDaemon(t, client) - 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, codersdk.Me, project.ID) - sched = "sdfasdfasdf asdf asdf" - ) - - cmd, root := clitest.New(t, "workspaces", "autostart", "enable", workspace.Name, sched) - clitest.SetupConfig(t, client, root) - - err := cmd.Execute() - require.ErrorContains(t, err, "failed to parse int from sdfasdfasdf: strconv.Atoi:", "unexpected error") - - // Ensure nothing happened - updated, err := client.Workspace(ctx, workspace.ID) - require.NoError(t, err, "fetch updated workspace") - require.Empty(t, updated.AutostartSchedule, "expected autostart schedule to be empty") - }) - - t.Run("Enable_NoSchedule", func(t *testing.T) { + t.Run("Enable_DefaultSchedule", func(t *testing.T) { t.Parallel() var ( @@ -137,15 +113,21 @@ func TestWorkspaceAutostart(t *testing.T) { workspace = coderdtest.CreateWorkspace(t, client, codersdk.Me, project.ID) ) + // check current TZ env var + currTz := os.Getenv("TZ") + if currTz == "" { + currTz = "UTC" + } + expectedSchedule := fmt.Sprintf("CRON_TZ=%s 0 9 * * 1-5", currTz) cmd, root := clitest.New(t, "workspaces", "autostart", "enable", workspace.Name) clitest.SetupConfig(t, client, root) err := cmd.Execute() - require.ErrorContains(t, err, "accepts 2 arg(s), received 1", "unexpected error") + require.NoError(t, err, "unexpected error") // Ensure nothing happened updated, err := client.Workspace(ctx, workspace.ID) require.NoError(t, err, "fetch updated workspace") - require.Empty(t, updated.AutostartSchedule, "expected autostart schedule to be empty") + require.Equal(t, expectedSchedule, updated.AutostartSchedule, "expected default autostart schedule") }) } diff --git a/cli/workspaceautostop.go b/cli/workspaceautostop.go index b8d26d75d914a..3b28af0a5e0cf 100644 --- a/cli/workspaceautostop.go +++ b/cli/workspaceautostop.go @@ -2,6 +2,7 @@ package cli import ( "fmt" + "os" "time" "github.com/spf13/cobra" @@ -11,20 +12,16 @@ import ( ) const autostopDescriptionLong = `To have your workspace stop automatically at a regular time you can enable autostop. -When enabling autostop, provide a schedule. This schedule is in cron format except only -the following fields are allowed: -- minute -- hour -- day of week - -For example, to stop your workspace every weekday at 5.30 pm, provide the schedule '30 17 1-5'.` +When enabling autostop, provide the minute, hour, and day(s) of week. +The default autostop schedule is at 18:00 in your local timezone (TZ env, UTC by default). +` func workspaceAutostop() *cobra.Command { autostopCmd := &cobra.Command{ - Use: "autostop enable ", - Short: "schedule a workspace to automatically start at a regular time", + Use: "autostop enable ", + Short: "schedule a workspace to automatically stop at a regular time", Long: autostopDescriptionLong, - Example: "coder workspaces autostop enable my-workspace '30 17 1-5'", + Example: "coder workspaces autostop enable my-workspace --minute 0 --hour 18 --days 1-5 -tz Europe/Dublin", Hidden: true, // TODO(cian): un-hide when autostop scheduling implemented } @@ -35,22 +32,28 @@ func workspaceAutostop() *cobra.Command { } func workspaceAutostopEnable() *cobra.Command { - return &cobra.Command{ + // yes some of these are technically numbers but the cron library will do that work + var autostopMinute string + var autostopHour string + var autostopDayOfWeek string + var autostopTimezone string + cmd := &cobra.Command{ Use: "enable ", ValidArgsFunction: validArgsWorkspaceName, - Args: cobra.ExactArgs(2), + Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { client, err := createClient(cmd) if err != nil { return err } - workspace, err := client.WorkspaceByName(cmd.Context(), codersdk.Me, args[0]) + spec := fmt.Sprintf("CRON_TZ=%s %s %s * * %s", autostopTimezone, autostopMinute, autostopHour, autostopDayOfWeek) + validSchedule, err := schedule.Weekly(spec) if err != nil { return err } - validSchedule, err := schedule.Weekly(args[1]) + workspace, err := client.WorkspaceByName(cmd.Context(), codersdk.Me, args[0]) if err != nil { return err } @@ -67,6 +70,16 @@ func workspaceAutostopEnable() *cobra.Command { return nil }, } + + cmd.Flags().StringVar(&autostopMinute, "minute", "0", "autostop minute") + cmd.Flags().StringVar(&autostopHour, "hour", "18", "autostop hour") + cmd.Flags().StringVar(&autostopDayOfWeek, "days", "1-5", "autostop day(s) of week") + tzEnv := os.Getenv("TZ") + if tzEnv == "" { + tzEnv = "UTC" + } + cmd.Flags().StringVar(&autostopTimezone, "tz", tzEnv, "autostop timezone") + return cmd } func workspaceAutostopDisable() *cobra.Command { diff --git a/cli/workspaceautostop_test.go b/cli/workspaceautostop_test.go index eb8fa583d2d61..f42deeab3b154 100644 --- a/cli/workspaceautostop_test.go +++ b/cli/workspaceautostop_test.go @@ -3,6 +3,8 @@ package cli_test import ( "bytes" "context" + "fmt" + "os" "testing" "github.com/stretchr/testify/require" @@ -27,11 +29,12 @@ func TestWorkspaceAutostop(t *testing.T) { _ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID) project = coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) workspace = coderdtest.CreateWorkspace(t, client, codersdk.Me, project.ID) - sched = "CRON_TZ=Europe/Dublin 30 9 1-5" + cmdArgs = []string{"workspaces", "autostop", "enable", workspace.Name, "--minute", "30", "--hour", "17", "--days", "1-5", "--tz", "Europe/Dublin"} + sched = "CRON_TZ=Europe/Dublin 30 17 * * 1-5" stdoutBuf = &bytes.Buffer{} ) - cmd, root := clitest.New(t, "workspaces", "autostop", "enable", workspace.Name, sched) + cmd, root := clitest.New(t, cmdArgs...) clitest.SetupConfig(t, client, root) cmd.SetOut(stdoutBuf) @@ -68,10 +71,9 @@ func TestWorkspaceAutostop(t *testing.T) { user = coderdtest.CreateFirstUser(t, client) version = coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) _ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID) - sched = "CRON_TZ=Europe/Dublin 30 9 1-5" ) - cmd, root := clitest.New(t, "workspaces", "autostop", "enable", "doesnotexist", sched) + cmd, root := clitest.New(t, "workspaces", "autostop", "enable", "doesnotexist") clitest.SetupConfig(t, client, root) err := cmd.Execute() @@ -96,7 +98,7 @@ func TestWorkspaceAutostop(t *testing.T) { require.ErrorContains(t, err, "status code 404: no workspace found by name", "unexpected error") }) - t.Run("Enable_InvalidSchedule", func(t *testing.T) { + t.Run("Enable_DefaultSchedule", func(t *testing.T) { t.Parallel() var ( @@ -108,44 +110,24 @@ func TestWorkspaceAutostop(t *testing.T) { _ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID) project = coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) workspace = coderdtest.CreateWorkspace(t, client, codersdk.Me, project.ID) - sched = "sdfasdfasdf asdf asdf" ) - cmd, root := clitest.New(t, "workspaces", "autostop", "enable", workspace.Name, sched) - clitest.SetupConfig(t, client, root) - - err := cmd.Execute() - require.ErrorContains(t, err, "failed to parse int from sdfasdfasdf: strconv.Atoi:", "unexpected error") - - // Ensure nothing happened - updated, err := client.Workspace(ctx, workspace.ID) - require.NoError(t, err, "fetch updated workspace") - require.Empty(t, updated.AutostopSchedule, "expected autostop schedule to be empty") - }) - - t.Run("Enable_NoSchedule", func(t *testing.T) { - t.Parallel() - - var ( - ctx = context.Background() - client = coderdtest.New(t, nil) - _ = coderdtest.NewProvisionerDaemon(t, client) - 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, codersdk.Me, project.ID) - ) + // check current TZ env var + currTz := os.Getenv("TZ") + if currTz == "" { + currTz = "UTC" + } + expectedSchedule := fmt.Sprintf("CRON_TZ=%s 0 18 * * 1-5", currTz) cmd, root := clitest.New(t, "workspaces", "autostop", "enable", workspace.Name) clitest.SetupConfig(t, client, root) err := cmd.Execute() - require.ErrorContains(t, err, "accepts 2 arg(s), received 1", "unexpected error") + require.NoError(t, err, "unexpected error") // Ensure nothing happened updated, err := client.Workspace(ctx, workspace.ID) require.NoError(t, err, "fetch updated workspace") - require.Empty(t, updated.AutostopSchedule, "expected autostop schedule to be empty") + require.Equal(t, expectedSchedule, updated.AutostopSchedule, "expected default autostop schedule") }) } diff --git a/coderd/autostart/schedule/schedule.go b/coderd/autostart/schedule/schedule.go index db9eeb9f3e4d7..e98d513bc04fd 100644 --- a/coderd/autostart/schedule/schedule.go +++ b/coderd/autostart/schedule/schedule.go @@ -3,6 +3,7 @@ package schedule import ( + "strings" "time" "github.com/robfig/cron/v3" @@ -10,16 +11,19 @@ import ( ) // For the purposes of this library, we only need minute, hour, and -// day-of-week. -const parserFormatWeekly = cron.Minute | cron.Hour | cron.Dow +// day-of-week. However to ensure interoperability we will use the standard +// five-valued cron format. Descriptors are not supported. +const parserFormat = cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow -var defaultParser = cron.NewParser(parserFormatWeekly) +var defaultParser = cron.NewParser(parserFormat) // Weekly parses a Schedule from spec scoped to a recurring weekly event. // Spec consists of the following space-delimited fields, in the following order: // - timezone e.g. CRON_TZ=US/Central (optional) // - minutes of hour e.g. 30 (required) // - hour of day e.g. 9 (required) +// - day of month (must be *) +// - month (must be *) // - day of week e.g. 1 (required) // // Example Usage: @@ -31,6 +35,10 @@ var defaultParser = cron.NewParser(parserFormatWeekly) // fmt.Println(sched.Next(time.Now()).Format(time.RFC3339)) // // Output: 2022-04-04T14:30:00Z func Weekly(spec string) (*Schedule, error) { + if err := validateWeeklySpec(spec); err != nil { + return nil, xerrors.Errorf("validate weekly schedule: %w", err) + } + specSched, err := defaultParser.Parse(spec) if err != nil { return nil, xerrors.Errorf("parse schedule: %w", err) @@ -65,3 +73,19 @@ func (s Schedule) String() string { func (s Schedule) Next(t time.Time) time.Time { return s.sched.Next(t) } + +// validateWeeklySpec ensures that the day-of-month and month options of +// spec are both set to * +func validateWeeklySpec(spec string) error { + parts := strings.Fields(spec) + if len(parts) < 5 { + return xerrors.Errorf("expected schedule to consist of 5 fields with an optional CRON_TZ= prefix") + } + if len(parts) == 6 { + parts = parts[1:] + } + if parts[2] != "*" || parts[3] != "*" { + return xerrors.Errorf("expected month and dom to be *") + } + return nil +} diff --git a/coderd/autostart/schedule/schedule_test.go b/coderd/autostart/schedule/schedule_test.go index 63383f38e6338..d29f5505270fa 100644 --- a/coderd/autostart/schedule/schedule_test.go +++ b/coderd/autostart/schedule/schedule_test.go @@ -20,14 +20,14 @@ func Test_Weekly(t *testing.T) { }{ { name: "with timezone", - spec: "CRON_TZ=US/Central 30 9 1-5", + 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), expectedError: "", }, { name: "without timezone", - spec: "30 9 1-5", + spec: "30 9 * * 1-5", at: time.Date(2022, 4, 1, 9, 29, 0, 0, time.Local), expectedNext: time.Date(2022, 4, 1, 9, 30, 0, 0, time.Local), expectedError: "", @@ -37,15 +37,43 @@ func Test_Weekly(t *testing.T) { spec: "asdfasdfasdfsd", at: time.Time{}, expectedNext: time.Time{}, - expectedError: "parse schedule: expected exactly 3 fields, found 1: [asdfasdfasdfsd]", + expectedError: "validate weekly schedule: expected schedule to consist of 5 fields with an optional CRON_TZ= prefix", }, { name: "invalid location", - spec: "CRON_TZ=Fictional/Country 30 9 1-5", + spec: "CRON_TZ=Fictional/Country 30 9 * * 1-5", at: time.Time{}, expectedNext: time.Time{}, expectedError: "parse schedule: provided bad location Fictional/Country: unknown time zone Fictional/Country", }, + { + name: "invalid schedule with 3 fields", + spec: "CRON_TZ=Fictional/Country 30 9 1-5", + at: time.Time{}, + expectedNext: time.Time{}, + expectedError: "validate weekly schedule: expected schedule to consist of 5 fields with an optional CRON_TZ= prefix", + }, + { + name: "invalid schedule with 3 fields and no timezone", + spec: "30 9 1-5", + at: time.Time{}, + expectedNext: time.Time{}, + expectedError: "validate weekly schedule: expected schedule to consist of 5 fields with an optional CRON_TZ= prefix", + }, + { + name: "valid schedule with 5 fields but month and dom not set to *", + spec: "30 9 1 1 1-5", + at: time.Time{}, + expectedNext: time.Time{}, + expectedError: "validate weekly schedule: expected month and dom to be *", + }, + { + name: "valid schedule with 5 fields and timezone but month and dom not set to *", + spec: "CRON_TZ=Europe/Dublin 30 9 1 1 1-5", + at: time.Time{}, + expectedNext: time.Time{}, + expectedError: "validate weekly schedule: expected month and dom to be *", + }, } for _, testCase := range testCases { diff --git a/coderd/workspaces_test.go b/coderd/workspaces_test.go index 8032656c62a25..a46d2eadaafab 100644 --- a/coderd/workspaces_test.go +++ b/coderd/workspaces_test.go @@ -205,7 +205,7 @@ func TestWorkspaceUpdateAutostart(t *testing.T) { }, { name: "friday to monday", - schedule: "CRON_TZ=Europe/Dublin 30 9 1-5", + schedule: "CRON_TZ=Europe/Dublin 30 9 * * 1-5", expectedError: "", at: time.Date(2022, 5, 6, 9, 31, 0, 0, dublinLoc), expectedNext: time.Date(2022, 5, 9, 9, 30, 0, 0, dublinLoc), @@ -213,7 +213,7 @@ func TestWorkspaceUpdateAutostart(t *testing.T) { }, { name: "monday to tuesday", - schedule: "CRON_TZ=Europe/Dublin 30 9 1-5", + schedule: "CRON_TZ=Europe/Dublin 30 9 * * 1-5", expectedError: "", at: time.Date(2022, 5, 9, 9, 31, 0, 0, dublinLoc), expectedNext: time.Date(2022, 5, 10, 9, 30, 0, 0, dublinLoc), @@ -222,7 +222,7 @@ func TestWorkspaceUpdateAutostart(t *testing.T) { { // DST in Ireland began on Mar 27 in 2022 at 0100. Forward 1 hour. name: "DST start", - schedule: "CRON_TZ=Europe/Dublin 30 9 *", + schedule: "CRON_TZ=Europe/Dublin 30 9 * * *", expectedError: "", at: time.Date(2022, 3, 26, 9, 31, 0, 0, dublinLoc), expectedNext: time.Date(2022, 3, 27, 9, 30, 0, 0, dublinLoc), @@ -231,7 +231,7 @@ func TestWorkspaceUpdateAutostart(t *testing.T) { { // DST in Ireland ends on Oct 30 in 2022 at 0200. Back 1 hour. name: "DST end", - schedule: "CRON_TZ=Europe/Dublin 30 9 *", + schedule: "CRON_TZ=Europe/Dublin 30 9 * * *", expectedError: "", at: time.Date(2022, 10, 29, 9, 31, 0, 0, dublinLoc), expectedNext: time.Date(2022, 10, 30, 9, 30, 0, 0, dublinLoc), @@ -239,13 +239,18 @@ func TestWorkspaceUpdateAutostart(t *testing.T) { }, { name: "invalid location", - schedule: "CRON_TZ=Imaginary/Place 30 9 1-5", + schedule: "CRON_TZ=Imaginary/Place 30 9 * * 1-5", expectedError: "status code 500: invalid autostart schedule: parse schedule: provided bad location Imaginary/Place: unknown time zone Imaginary/Place", }, { name: "invalid schedule", schedule: "asdf asdf asdf ", - expectedError: `status code 500: invalid autostart schedule: parse schedule: failed to parse int from asdf: strconv.Atoi: parsing "asdf": invalid syntax`, + expectedError: `status code 500: invalid autostart schedule: validate weekly schedule: expected schedule to consist of 5 fields with an optional CRON_TZ= prefix`, + }, + { + name: "only 3 values", + schedule: "CRON_TZ=Europe/Dublin 30 9 *", + expectedError: `status code 500: invalid autostart schedule: validate weekly schedule: expected schedule to consist of 5 fields with an optional CRON_TZ= prefix`, }, } @@ -334,7 +339,7 @@ func TestWorkspaceUpdateAutostop(t *testing.T) { }, { name: "friday to monday", - schedule: "CRON_TZ=Europe/Dublin 30 17 1-5", + schedule: "CRON_TZ=Europe/Dublin 30 17 * * 1-5", expectedError: "", at: time.Date(2022, 5, 6, 17, 31, 0, 0, dublinLoc), expectedNext: time.Date(2022, 5, 9, 17, 30, 0, 0, dublinLoc), @@ -342,7 +347,7 @@ func TestWorkspaceUpdateAutostop(t *testing.T) { }, { name: "monday to tuesday", - schedule: "CRON_TZ=Europe/Dublin 30 17 1-5", + schedule: "CRON_TZ=Europe/Dublin 30 17 * * 1-5", expectedError: "", at: time.Date(2022, 5, 9, 17, 31, 0, 0, dublinLoc), expectedNext: time.Date(2022, 5, 10, 17, 30, 0, 0, dublinLoc), @@ -351,7 +356,7 @@ func TestWorkspaceUpdateAutostop(t *testing.T) { { // DST in Ireland began on Mar 27 in 2022 at 0100. Forward 1 hour. name: "DST start", - schedule: "CRON_TZ=Europe/Dublin 30 17 *", + schedule: "CRON_TZ=Europe/Dublin 30 17 * * *", expectedError: "", at: time.Date(2022, 3, 26, 17, 31, 0, 0, dublinLoc), expectedNext: time.Date(2022, 3, 27, 17, 30, 0, 0, dublinLoc), @@ -360,7 +365,7 @@ func TestWorkspaceUpdateAutostop(t *testing.T) { { // DST in Ireland ends on Oct 30 in 2022 at 0200. Back 1 hour. name: "DST end", - schedule: "CRON_TZ=Europe/Dublin 30 17 *", + schedule: "CRON_TZ=Europe/Dublin 30 17 * * *", expectedError: "", at: time.Date(2022, 10, 29, 17, 31, 0, 0, dublinLoc), expectedNext: time.Date(2022, 10, 30, 17, 30, 0, 0, dublinLoc), @@ -368,13 +373,18 @@ func TestWorkspaceUpdateAutostop(t *testing.T) { }, { name: "invalid location", - schedule: "CRON_TZ=Imaginary/Place 30 17 1-5", + schedule: "CRON_TZ=Imaginary/Place 30 17 * * 1-5", expectedError: "status code 500: invalid autostop schedule: parse schedule: provided bad location Imaginary/Place: unknown time zone Imaginary/Place", }, { name: "invalid schedule", schedule: "asdf asdf asdf ", - expectedError: `status code 500: invalid autostop schedule: parse schedule: failed to parse int from asdf: strconv.Atoi: parsing "asdf": invalid syntax`, + expectedError: `status code 500: invalid autostop schedule: validate weekly schedule: expected schedule to consist of 5 fields with an optional CRON_TZ= prefix`, + }, + { + name: "only 3 values", + schedule: "CRON_TZ=Europe/Dublin 30 9 *", + expectedError: `status code 500: invalid autostop schedule: validate weekly schedule: expected schedule to consist of 5 fields with an optional CRON_TZ= prefix`, }, } diff --git a/site/src/components/Workspace/WorkspaceSchedule.tsx b/site/src/components/Workspace/WorkspaceSchedule.tsx index bda8581fe03af..4e44cd79c1a31 100644 --- a/site/src/components/Workspace/WorkspaceSchedule.tsx +++ b/site/src/components/Workspace/WorkspaceSchedule.tsx @@ -2,7 +2,7 @@ import Box from "@material-ui/core/Box" import Typography from "@material-ui/core/Typography" import cronstrue from "cronstrue" import React from "react" -import { expandScheduleCronString, extractTimezone } from "../../util/schedule" +import { extractTimezone, stripTimezone } from "../../util/schedule" import { WorkspaceSection } from "./WorkspaceSection" const Language = { @@ -26,7 +26,7 @@ const Language = { }, cronHumanDisplay: (schedule: string): string => { if (schedule) { - return cronstrue.toString(expandScheduleCronString(schedule), { throwExceptionOnParseError: false }) + return cronstrue.toString(stripTimezone(schedule), { throwExceptionOnParseError: false }) } return "Manual" }, diff --git a/site/src/test_helpers/entities.ts b/site/src/test_helpers/entities.ts index bdccc8bb806ac..a531c830710a1 100644 --- a/site/src/test_helpers/entities.ts +++ b/site/src/test_helpers/entities.ts @@ -68,7 +68,7 @@ export const MockWorkspaceAutostartDisabled: WorkspaceAutostartRequest = { export const MockWorkspaceAutostartEnabled: WorkspaceAutostartRequest = { // Runs at 9:30am Monday through Friday using Canada/Eastern // (America/Toronto) time - schedule: "CRON_TZ=Canada/Eastern 30 9 1-5", + schedule: "CRON_TZ=Canada/Eastern 30 9 * * 1-5", } export const MockWorkspaceAutostopDisabled: WorkspaceAutostartRequest = { @@ -77,7 +77,7 @@ export const MockWorkspaceAutostopDisabled: WorkspaceAutostartRequest = { export const MockWorkspaceAutostopEnabled: WorkspaceAutostartRequest = { // Runs at 9:30pm Monday through Friday using America/Toronto - schedule: "CRON_TZ=America/Toronto 30 21 1-5", + schedule: "CRON_TZ=America/Toronto 30 21 * * 1-5", } export const MockWorkspace: Workspace = { diff --git a/site/src/util/schedule.test.ts b/site/src/util/schedule.test.ts index 6cf936b0d1e24..d7ed65299cd67 100644 --- a/site/src/util/schedule.test.ts +++ b/site/src/util/schedule.test.ts @@ -1,4 +1,4 @@ -import { expandScheduleCronString, extractTimezone, stripTimezone } from "./schedule" +import { extractTimezone, stripTimezone } from "./schedule" describe("util/schedule", () => { describe("stripTimezone", () => { @@ -20,14 +20,4 @@ describe("util/schedule", () => { expect(extractTimezone(input)).toBe(expected) }) }) - - describe("expandScheduleCronString", () => { - it.each<[string, string]>([ - ["CRON_TZ=Canada/Eastern 30 9 1-5", "30 9 * * 1-5"], - ["CRON_TZ=America/Central 0 8 1,2,4,5", "0 8 * * 1,2,4,5"], - ["30 9 1-5", "30 9 * * 1-5"], - ])(`expandScheduleCronString(%p) returns %p`, (input, expected) => { - expect(expandScheduleCronString(input)).toBe(expected) - }) - }) }) diff --git a/site/src/util/schedule.ts b/site/src/util/schedule.ts index dc082233a7a8e..55c26aadfea14 100644 --- a/site/src/util/schedule.ts +++ b/site/src/util/schedule.ts @@ -30,25 +30,3 @@ export const extractTimezone = (raw: string): string => { return DEFAULT_TIMEZONE } } - -/** - * expandScheduleCronString ensures a Schedule is expanded to a valid 5-value - * cron string by inserting '*' in month and day positions. If there is a - * leading timezone, it is removed. - * - * @example - * expandScheduleCronString("30 9 1-5") // -> "30 9 * * 1-5" - */ -export const expandScheduleCronString = (schedule: string): string => { - const prepared = stripTimezone(schedule).trim() - - const parts = prepared.split(" ") - - while (parts.length < 5) { - // insert '*' in the second to last position - // ie [a, b, c] --> [a, b, *, c] - parts.splice(parts.length - 1, 0, "*") - } - - return parts.join(" ") -}