Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit 0a949aa

Browse files
authored
cli: streamline autostart ux (#2251)
This commit adds the following changes: - autostart enable|disable => autostart set|unset - autostart enable now accepts a more natual schedule format: <time> <days-of-week> <location> - autostart show now shows configured timezone - 🎉 automatic timezone detection across mac, windows, linux 🎉 Fixes #1647
1 parent 9d15584 commit 0a949aa

File tree

10 files changed

+584
-148
lines changed

10 files changed

+584
-148
lines changed

cli/autostart.go

+123-34
Original file line numberDiff line numberDiff line change
@@ -2,32 +2,41 @@ package cli
22

33
import (
44
"fmt"
5-
"os"
5+
"strings"
66
"time"
77

88
"github.com/spf13/cobra"
9+
"golang.org/x/xerrors"
910

1011
"github.com/coder/coder/coderd/autobuild/schedule"
12+
"github.com/coder/coder/coderd/util/ptr"
13+
"github.com/coder/coder/coderd/util/tz"
1114
"github.com/coder/coder/codersdk"
1215
)
1316

1417
const autostartDescriptionLong = `To have your workspace build automatically at a regular time you can enable autostart.
15-
When enabling autostart, provide the minute, hour, and day(s) of week.
16-
The default schedule is at 09:00 in your local timezone (TZ env, UTC by default).
18+
When enabling autostart, enter a schedule in the format: <start-time> [day-of-week] [location].
19+
* Start-time (required) is accepted either in 12-hour (hh:mm{am|pm}) format, or 24-hour format hh:mm.
20+
* Day-of-week (optional) allows specifying in the cron format, e.g. 1,3,5 or Mon-Fri.
21+
Aliases such as @daily are not supported.
22+
Default: * (every day)
23+
* Location (optional) must be a valid location in the IANA timezone database.
24+
If omitted, we will fall back to either the TZ environment variable or /etc/localtime.
25+
You can check your corresponding location by visiting https://ipinfo.io - it shows in the demo widget on the right.
1726
`
1827

1928
func autostart() *cobra.Command {
2029
autostartCmd := &cobra.Command{
2130
Annotations: workspaceCommand,
22-
Use: "autostart enable <workspace>",
31+
Use: "autostart set <workspace> <start-time> [day-of-week] [location]",
2332
Short: "schedule a workspace to automatically start at a regular time",
2433
Long: autostartDescriptionLong,
25-
Example: "coder autostart enable my-workspace --minute 30 --hour 9 --days 1-5 --tz Europe/Dublin",
34+
Example: "coder autostart set my-workspace 9:30AM Mon-Fri Europe/Dublin",
2635
}
2736

2837
autostartCmd.AddCommand(autostartShow())
29-
autostartCmd.AddCommand(autostartEnable())
30-
autostartCmd.AddCommand(autostartDisable())
38+
autostartCmd.AddCommand(autostartSet())
39+
autostartCmd.AddCommand(autostartUnset())
3140

3241
return autostartCmd
3342
}
@@ -60,13 +69,12 @@ func autostartShow() *cobra.Command {
6069
}
6170

6271
next := validSchedule.Next(time.Now())
63-
loc, _ := time.LoadLocation(validSchedule.Timezone())
6472

6573
_, _ = fmt.Fprintf(cmd.OutOrStdout(),
6674
"schedule: %s\ntimezone: %s\nnext: %s\n",
6775
validSchedule.Cron(),
68-
validSchedule.Timezone(),
69-
next.In(loc),
76+
validSchedule.Location(),
77+
next.In(validSchedule.Location()),
7078
)
7179

7280
return nil
@@ -75,23 +83,17 @@ func autostartShow() *cobra.Command {
7583
return cmd
7684
}
7785

78-
func autostartEnable() *cobra.Command {
79-
// yes some of these are technically numbers but the cron library will do that work
80-
var autostartMinute string
81-
var autostartHour string
82-
var autostartDayOfWeek string
83-
var autostartTimezone string
86+
func autostartSet() *cobra.Command {
8487
cmd := &cobra.Command{
85-
Use: "enable <workspace_name> <schedule>",
86-
Args: cobra.ExactArgs(1),
88+
Use: "set <workspace_name> <start-time> [day-of-week] [location]",
89+
Args: cobra.RangeArgs(2, 4),
8790
RunE: func(cmd *cobra.Command, args []string) error {
8891
client, err := createClient(cmd)
8992
if err != nil {
9093
return err
9194
}
9295

93-
spec := fmt.Sprintf("CRON_TZ=%s %s %s * * %s", autostartTimezone, autostartMinute, autostartHour, autostartDayOfWeek)
94-
validSchedule, err := schedule.Weekly(spec)
96+
sched, err := parseCLISchedule(args[1:]...)
9597
if err != nil {
9698
return err
9799
}
@@ -102,32 +104,30 @@ func autostartEnable() *cobra.Command {
102104
}
103105

104106
err = client.UpdateWorkspaceAutostart(cmd.Context(), workspace.ID, codersdk.UpdateWorkspaceAutostartRequest{
105-
Schedule: &spec,
107+
Schedule: ptr.Ref(sched.String()),
106108
})
107109
if err != nil {
108110
return err
109111
}
110112

111-
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "\nThe %s workspace will automatically start at %s.\n\n", workspace.Name, validSchedule.Next(time.Now()))
112-
113+
schedNext := sched.Next(time.Now())
114+
_, _ = fmt.Fprintf(cmd.OutOrStdout(),
115+
"%s will automatically start at %s %s (%s)\n",
116+
workspace.Name,
117+
schedNext.In(sched.Location()).Format(time.Kitchen),
118+
sched.DaysOfWeek(),
119+
sched.Location().String(),
120+
)
113121
return nil
114122
},
115123
}
116124

117-
cmd.Flags().StringVar(&autostartMinute, "minute", "0", "autostart minute")
118-
cmd.Flags().StringVar(&autostartHour, "hour", "9", "autostart hour")
119-
cmd.Flags().StringVar(&autostartDayOfWeek, "days", "1-5", "autostart day(s) of week")
120-
tzEnv := os.Getenv("TZ")
121-
if tzEnv == "" {
122-
tzEnv = "UTC"
123-
}
124-
cmd.Flags().StringVar(&autostartTimezone, "tz", tzEnv, "autostart timezone")
125125
return cmd
126126
}
127127

128-
func autostartDisable() *cobra.Command {
128+
func autostartUnset() *cobra.Command {
129129
return &cobra.Command{
130-
Use: "disable <workspace_name>",
130+
Use: "unset <workspace_name>",
131131
Args: cobra.ExactArgs(1),
132132
RunE: func(cmd *cobra.Command, args []string) error {
133133
client, err := createClient(cmd)
@@ -147,9 +147,98 @@ func autostartDisable() *cobra.Command {
147147
return err
148148
}
149149

150-
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "\nThe %s workspace will no longer automatically start.\n\n", workspace.Name)
150+
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "%s will no longer automatically start.\n", workspace.Name)
151151

152152
return nil
153153
},
154154
}
155155
}
156+
157+
var errInvalidScheduleFormat = xerrors.New("Schedule must be in the format Mon-Fri 09:00AM America/Chicago")
158+
var errInvalidTimeFormat = xerrors.New("Start time must be in the format hh:mm[am|pm] or HH:MM")
159+
var errUnsupportedTimezone = xerrors.New("The location you provided looks like a timezone. Check https://ipinfo.io for your location.")
160+
161+
// parseCLISchedule parses a schedule in the format HH:MM{AM|PM} [DOW] [LOCATION]
162+
func parseCLISchedule(parts ...string) (*schedule.Schedule, error) {
163+
// If the user was careful and quoted the schedule, un-quote it.
164+
// In the case that only time was specified, this will be a no-op.
165+
if len(parts) == 1 {
166+
parts = strings.Fields(parts[0])
167+
}
168+
var loc *time.Location
169+
dayOfWeek := "*"
170+
t, err := parseTime(parts[0])
171+
if err != nil {
172+
return nil, err
173+
}
174+
hour, minute := t.Hour(), t.Minute()
175+
176+
// Any additional parts get ignored.
177+
switch len(parts) {
178+
case 3:
179+
dayOfWeek = parts[1]
180+
loc, err = time.LoadLocation(parts[2])
181+
if err != nil {
182+
_, err = time.Parse("MST", parts[2])
183+
if err == nil {
184+
return nil, errUnsupportedTimezone
185+
}
186+
return nil, xerrors.Errorf("Invalid timezone %q specified: a valid IANA timezone is required", parts[2])
187+
}
188+
case 2:
189+
// Did they provide day-of-week or location?
190+
if maybeLoc, err := time.LoadLocation(parts[1]); err != nil {
191+
// Assume day-of-week.
192+
dayOfWeek = parts[1]
193+
} else {
194+
loc = maybeLoc
195+
}
196+
case 1: // already handled
197+
default:
198+
return nil, errInvalidScheduleFormat
199+
}
200+
201+
// If location was not specified, attempt to automatically determine it as a last resort.
202+
if loc == nil {
203+
loc, err = tz.TimezoneIANA()
204+
if err != nil {
205+
return nil, xerrors.Errorf("Could not automatically determine your timezone")
206+
}
207+
}
208+
209+
sched, err := schedule.Weekly(fmt.Sprintf(
210+
"CRON_TZ=%s %d %d * * %s",
211+
loc.String(),
212+
minute,
213+
hour,
214+
dayOfWeek,
215+
))
216+
if err != nil {
217+
// This will either be an invalid dayOfWeek or an invalid timezone.
218+
return nil, xerrors.Errorf("Invalid schedule: %w", err)
219+
}
220+
221+
return sched, nil
222+
}
223+
224+
func parseTime(s string) (time.Time, error) {
225+
// Try a number of possible layouts.
226+
for _, layout := range []string{
227+
time.Kitchen, // 03:04PM
228+
"03:04pm",
229+
"3:04PM",
230+
"3:04pm",
231+
"15:04",
232+
"1504",
233+
"03PM",
234+
"03pm",
235+
"3PM",
236+
"3pm",
237+
} {
238+
t, err := time.Parse(layout, s)
239+
if err == nil {
240+
return t, nil
241+
}
242+
}
243+
return time.Time{}, errInvalidTimeFormat
244+
}

cli/autostart_internal_test.go

+119
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
package cli
2+
3+
import (
4+
"testing"
5+
6+
"github.com/stretchr/testify/assert"
7+
)
8+
9+
//nolint:paralleltest // t.Setenv
10+
func TestParseCLISchedule(t *testing.T) {
11+
for _, testCase := range []struct {
12+
name string
13+
input []string
14+
expectedSchedule string
15+
expectedError string
16+
tzEnv string
17+
}{
18+
{
19+
name: "TimeAndDayOfWeekAndLocation",
20+
input: []string{"09:00AM", "Sun-Sat", "America/Chicago"},
21+
expectedSchedule: "CRON_TZ=America/Chicago 0 9 * * Sun-Sat",
22+
tzEnv: "UTC",
23+
},
24+
{
25+
name: "TimeOfDay24HourAndDayOfWeekAndLocation",
26+
input: []string{"09:00", "Sun-Sat", "America/Chicago"},
27+
expectedSchedule: "CRON_TZ=America/Chicago 0 9 * * Sun-Sat",
28+
tzEnv: "UTC",
29+
},
30+
{
31+
name: "TimeOfDay24HourAndDayOfWeekAndLocationButItsAllQuoted",
32+
input: []string{"09:00 Sun-Sat America/Chicago"},
33+
expectedSchedule: "CRON_TZ=America/Chicago 0 9 * * Sun-Sat",
34+
tzEnv: "UTC",
35+
},
36+
{
37+
name: "TimeOfDayOnly",
38+
input: []string{"09:00AM"},
39+
expectedSchedule: "CRON_TZ=America/Chicago 0 9 * * *",
40+
tzEnv: "America/Chicago",
41+
},
42+
{
43+
name: "Time24Military",
44+
input: []string{"0900"},
45+
expectedSchedule: "CRON_TZ=America/Chicago 0 9 * * *",
46+
tzEnv: "America/Chicago",
47+
},
48+
{
49+
name: "DayOfWeekAndTime",
50+
input: []string{"09:00AM", "Sun-Sat"},
51+
expectedSchedule: "CRON_TZ=America/Chicago 0 9 * * Sun-Sat",
52+
tzEnv: "America/Chicago",
53+
},
54+
{
55+
name: "TimeAndLocation",
56+
input: []string{"09:00AM", "America/Chicago"},
57+
expectedSchedule: "CRON_TZ=America/Chicago 0 9 * * *",
58+
tzEnv: "UTC",
59+
},
60+
{
61+
name: "LazyTime",
62+
input: []string{"9am", "America/Chicago"},
63+
expectedSchedule: "CRON_TZ=America/Chicago 0 9 * * *",
64+
tzEnv: "UTC",
65+
},
66+
{
67+
name: "ZeroPrefixedLazyTime",
68+
input: []string{"09am", "America/Chicago"},
69+
expectedSchedule: "CRON_TZ=America/Chicago 0 9 * * *",
70+
tzEnv: "UTC",
71+
},
72+
{
73+
name: "InvalidTime",
74+
input: []string{"nine"},
75+
expectedError: errInvalidTimeFormat.Error(),
76+
},
77+
{
78+
name: "DayOfWeekAndInvalidTime",
79+
input: []string{"nine", "Sun-Sat"},
80+
expectedError: errInvalidTimeFormat.Error(),
81+
},
82+
{
83+
name: "InvalidTimeAndLocation",
84+
input: []string{"nine", "America/Chicago"},
85+
expectedError: errInvalidTimeFormat.Error(),
86+
},
87+
{
88+
name: "DayOfWeekAndInvalidTimeAndLocation",
89+
input: []string{"nine", "Sun-Sat", "America/Chicago"},
90+
expectedError: errInvalidTimeFormat.Error(),
91+
},
92+
{
93+
name: "TimezoneProvidedInsteadOfLocation",
94+
input: []string{"09:00AM", "Sun-Sat", "CST"},
95+
expectedError: errUnsupportedTimezone.Error(),
96+
},
97+
{
98+
name: "WhoKnows",
99+
input: []string{"Time", "is", "a", "human", "construct"},
100+
expectedError: errInvalidTimeFormat.Error(),
101+
},
102+
} {
103+
testCase := testCase
104+
//nolint:paralleltest // t.Setenv
105+
t.Run(testCase.name, func(t *testing.T) {
106+
t.Setenv("TZ", testCase.tzEnv)
107+
actualSchedule, actualError := parseCLISchedule(testCase.input...)
108+
if testCase.expectedError != "" {
109+
assert.Nil(t, actualSchedule)
110+
assert.ErrorContains(t, actualError, testCase.expectedError)
111+
return
112+
}
113+
assert.NoError(t, actualError)
114+
if assert.NotEmpty(t, actualSchedule) {
115+
assert.Equal(t, testCase.expectedSchedule, actualSchedule.String())
116+
}
117+
})
118+
}
119+
}

0 commit comments

Comments
 (0)