@@ -2,32 +2,41 @@ package cli
22
33import (
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
1417const 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
1928func 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\n timezone: %s\n next: %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 (), "\n The %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 (), "\n The %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+ }
0 commit comments