Skip to content

Commit

Permalink
autostart/autostop: move to traditional 5-valued cron string for comp…
Browse files Browse the repository at this point in the history
…atibility (#1049)

This PR modfies the original 3-valued cron strings used in package schedule to be traditional 5-valued cron strings.

- schedule.Weekly will validate that the month and dom fields are equal to *
- cli autostart/autostop will attempt to detect local timezone using TZ env var, defaulting to UTC
- cli autostart/autostop no longer accepts a raw schedule -- instead use the --minute, --hour, --dow, and --tz arguments.
- Default schedules are provided that should suffice for most users.

Fixes #993
  • Loading branch information
johnstcn committed Apr 18, 2022
1 parent 3311c2f commit af67280
Show file tree
Hide file tree
Showing 11 changed files with 170 additions and 150 deletions.
39 changes: 26 additions & 13 deletions cli/workspaceautostart.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package cli

import (
"fmt"
"os"
"time"

"github.com/spf13/cobra"
Expand All @@ -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 <workspace> <schedule>",
Use: "autostart enable <workspace>",
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
}

Expand All @@ -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 <workspace_name> <schedule>",
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
}
Expand All @@ -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 {
Expand Down
50 changes: 16 additions & 34 deletions cli/workspaceautostart_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ package cli_test
import (
"bytes"
"context"
"fmt"
"os"
"testing"

"github.com/stretchr/testify/require"
Expand All @@ -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)

Expand Down Expand Up @@ -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()
Expand All @@ -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 (
Expand All @@ -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")
})
}
41 changes: 27 additions & 14 deletions cli/workspaceautostop.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package cli

import (
"fmt"
"os"
"time"

"github.com/spf13/cobra"
Expand All @@ -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 <workspace> <schedule>",
Short: "schedule a workspace to automatically start at a regular time",
Use: "autostop enable <workspace>",
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
}

Expand All @@ -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 <workspace_name> <schedule>",
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
}
Expand All @@ -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 {
Expand Down
48 changes: 15 additions & 33 deletions cli/workspaceautostop_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ package cli_test
import (
"bytes"
"context"
"fmt"
"os"
"testing"

"github.com/stretchr/testify/require"
Expand All @@ -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)

Expand Down Expand Up @@ -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()
Expand All @@ -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 (
Expand All @@ -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")
})
}

0 comments on commit af67280

Please sign in to comment.