Skip to content

cli: streamline autostart ux #2251

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 14 commits into from
Jun 13, 2022
Next Next commit
wip
  • Loading branch information
johnstcn committed Jun 9, 2022
commit 7c7a290cddb7ec62f5fbd10f2b4c61a69b7dbcd0
140 changes: 112 additions & 28 deletions cli/autostart.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This help is ❤️.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, this is excellent.

`

func autostart() *cobra.Command {
Expand All @@ -22,12 +31,12 @@ func autostart() *cobra.Command {
Use: "autostart enable <workspace>",
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
}
Expand Down Expand Up @@ -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 <workspace_name> <schedule>",
Args: cobra.ExactArgs(1),
Use: "set <workspace_name> ",
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
}
Expand All @@ -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 <workspace_name>",
Use: "unset <workspace_name>",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
client, err := createClient(cmd)
Expand All @@ -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
}
90 changes: 90 additions & 0 deletions cli/autostart_internal_test.go
Original file line number Diff line number Diff line change
@@ -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)
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: Doing setenv here to validate that the timezone specified in the schedule is used

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())
}
})
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These are some beautiful tests! ❤️

}
}
Loading