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

Lines changed: 123 additions & 34 deletions
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

Lines changed: 119 additions & 0 deletions
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)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy