Skip to content

Commit 7c7a290

Browse files
committed
wip
1 parent 567e4af commit 7c7a290

File tree

6 files changed

+359
-62
lines changed

6 files changed

+359
-62
lines changed

cli/autostart.go

Lines changed: 112 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,27 @@ 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: [day-of-week] start-time [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 {
@@ -22,12 +31,12 @@ func autostart() *cobra.Command {
2231
Use: "autostart enable <workspace>",
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 Mon-Fri 9:30AM 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
}
@@ -75,23 +84,17 @@ func autostartShow() *cobra.Command {
7584
return cmd
7685
}
7786

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
87+
func autostartSet() *cobra.Command {
8488
cmd := &cobra.Command{
85-
Use: "enable <workspace_name> <schedule>",
86-
Args: cobra.ExactArgs(1),
89+
Use: "set <workspace_name> ",
90+
Args: cobra.MinimumNArgs(2),
8791
RunE: func(cmd *cobra.Command, args []string) error {
8892
client, err := createClient(cmd)
8993
if err != nil {
9094
return err
9195
}
9296

93-
spec := fmt.Sprintf("CRON_TZ=%s %s %s * * %s", autostartTimezone, autostartMinute, autostartHour, autostartDayOfWeek)
94-
validSchedule, err := schedule.Weekly(spec)
97+
sched, err := parseCLISchedule(args[1:]...)
9598
if err != nil {
9699
return err
97100
}
@@ -102,32 +105,24 @@ func autostartEnable() *cobra.Command {
102105
}
103106

104107
err = client.UpdateWorkspaceAutostart(cmd.Context(), workspace.ID, codersdk.UpdateWorkspaceAutostartRequest{
105-
Schedule: &spec,
108+
Schedule: ptr.Ref(sched.String()),
106109
})
107110
if err != nil {
108111
return err
109112
}
110113

111-
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "\nThe %s workspace will automatically start at %s.\n\n", workspace.Name, validSchedule.Next(time.Now()))
114+
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "\nThe %s workspace will automatically start at %s.\n\n", workspace.Name, sched.Next(time.Now()))
112115

113116
return nil
114117
},
115118
}
116119

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")
125120
return cmd
126121
}
127122

128-
func autostartDisable() *cobra.Command {
123+
func autostartUnset() *cobra.Command {
129124
return &cobra.Command{
130-
Use: "disable <workspace_name>",
125+
Use: "unset <workspace_name>",
131126
Args: cobra.ExactArgs(1),
132127
RunE: func(cmd *cobra.Command, args []string) error {
133128
client, err := createClient(cmd)
@@ -153,3 +148,92 @@ func autostartDisable() *cobra.Command {
153148
},
154149
}
155150
}
151+
152+
var errInvalidScheduleFormat = xerrors.New("Schedule must be in the format Mon-Fri 09:00AM America/Chicago")
153+
var errInvalidTimeFormat = xerrors.New("Start time must be in the format hh:mm[am|pm] or HH:MM")
154+
155+
// parseCLISchedule parses a schedule in the format Mon-Fri 09:00AM America/Chicago.
156+
func parseCLISchedule(parts ...string) (*schedule.Schedule, error) {
157+
// If the user was careful and quoted the schedule, un-quote it.
158+
// In the case that only time was specified, this will be a no-op.
159+
if len(parts) == 1 {
160+
parts = strings.Fields(parts[0])
161+
}
162+
timezone := ""
163+
dayOfWeek := "*"
164+
var hour, minute int
165+
switch len(parts) {
166+
case 1:
167+
t, err := parseTime(parts[0])
168+
if err != nil {
169+
return nil, err
170+
}
171+
hour, minute = t.Hour(), t.Minute()
172+
case 2:
173+
if !strings.Contains(parts[0], ":") {
174+
// DOW + Time
175+
t, err := parseTime(parts[1])
176+
if err != nil {
177+
return nil, err
178+
}
179+
hour, minute = t.Hour(), t.Minute()
180+
dayOfWeek = parts[0]
181+
} else {
182+
// Time + TZ
183+
t, err := parseTime(parts[0])
184+
if err != nil {
185+
return nil, err
186+
}
187+
hour, minute = t.Hour(), t.Minute()
188+
timezone = parts[1]
189+
}
190+
case 3:
191+
// DOW + Time + TZ
192+
t, err := parseTime(parts[1])
193+
if err != nil {
194+
return nil, err
195+
}
196+
hour, minute = t.Hour(), t.Minute()
197+
dayOfWeek = parts[0]
198+
timezone = parts[2]
199+
default:
200+
return nil, errInvalidScheduleFormat
201+
}
202+
203+
// If timezone was not specified, attempt to automatically determine it as a last resort.
204+
if timezone == "" {
205+
loc, err := tz.TimezoneIANA()
206+
if err != nil {
207+
return nil, xerrors.Errorf("Could not automatically determine your timezone.")
208+
}
209+
timezone = loc.String()
210+
}
211+
212+
sched, err := schedule.Weekly(fmt.Sprintf(
213+
"CRON_TZ=%s %d %d * * %s",
214+
timezone,
215+
minute,
216+
hour,
217+
dayOfWeek,
218+
))
219+
if err != nil {
220+
// This will either be an invalid dayOfWeek or an invalid timezone.
221+
return nil, xerrors.Errorf("Invalid schedule: %w", err)
222+
}
223+
224+
return sched, nil
225+
}
226+
227+
func parseTime(s string) (time.Time, error) {
228+
// Assume only time provided, HH:MM[AM|PM]
229+
t, err := time.Parse(time.Kitchen, s)
230+
if err == nil {
231+
return t, nil
232+
}
233+
// Try 24-hour format without AM/PM suffix.
234+
t, err = time.Parse("15:04", s)
235+
if err != nil {
236+
return time.Time{}, errInvalidTimeFormat
237+
}
238+
return t, nil
239+
}

cli/autostart_internal_test.go

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
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: "DefaultSchedule",
20+
input: []string{"Sun-Sat", "09:00AM", "America/Chicago"},
21+
expectedSchedule: "CRON_TZ=America/Chicago 0 9 * * Sun-Sat",
22+
tzEnv: "UTC",
23+
},
24+
{
25+
name: "DefaultSchedule24Hour",
26+
input: []string{"Sun-Sat", "09:00", "America/Chicago"},
27+
expectedSchedule: "CRON_TZ=America/Chicago 0 9 * * Sun-Sat",
28+
tzEnv: "UTC",
29+
},
30+
{
31+
name: "TimeOfDayOnly",
32+
input: []string{"09:00AM"},
33+
expectedSchedule: "CRON_TZ=America/Chicago 0 9 * * *",
34+
tzEnv: "America/Chicago",
35+
},
36+
{
37+
name: "DayOfWeekAndTime",
38+
input: []string{"Sun-Sat", "09:00AM"},
39+
expectedSchedule: "CRON_TZ=America/Chicago 0 9 * * Sun-Sat",
40+
tzEnv: "America/Chicago",
41+
},
42+
{
43+
name: "TimeAndLocation",
44+
input: []string{"09:00AM", "America/Chicago"},
45+
expectedSchedule: "CRON_TZ=America/Chicago 0 9 * * *",
46+
tzEnv: "UTC",
47+
},
48+
{
49+
name: "InvalidTime",
50+
input: []string{"9am"},
51+
expectedError: errInvalidTimeFormat.Error(),
52+
},
53+
{
54+
name: "DayOfWeekAndInvalidTime",
55+
input: []string{"Sun-Sat", "9am"},
56+
expectedError: errInvalidTimeFormat.Error(),
57+
},
58+
{
59+
name: "InvalidTimeAndLocation",
60+
input: []string{"9:", "America/Chicago"},
61+
expectedError: errInvalidTimeFormat.Error(),
62+
},
63+
{
64+
name: "DayOfWeekAndInvalidTimeAndLocation",
65+
input: []string{"Sun-Sat", "9am", "America/Chicago"},
66+
expectedError: errInvalidTimeFormat.Error(),
67+
},
68+
{
69+
name: "WhoKnows",
70+
input: []string{"Time", "is", "a", "human", "construct"},
71+
expectedError: errInvalidScheduleFormat.Error(),
72+
},
73+
} {
74+
testCase := testCase
75+
//nolint:paralleltest // t.Setenv
76+
t.Run(testCase.name, func(t *testing.T) {
77+
t.Setenv("TZ", testCase.tzEnv)
78+
actualSchedule, actualError := parseCLISchedule(testCase.input...)
79+
if testCase.expectedError != "" {
80+
assert.Nil(t, actualSchedule)
81+
assert.ErrorContains(t, actualError, testCase.expectedError)
82+
return
83+
}
84+
assert.NoError(t, actualError)
85+
if assert.NotEmpty(t, actualSchedule) {
86+
assert.Equal(t, testCase.expectedSchedule, actualSchedule.String())
87+
}
88+
})
89+
}
90+
}

0 commit comments

Comments
 (0)