From 7523edf12aac0a2fe9235d6b6358f59aa9cc9d6f Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Tue, 30 Apr 2024 15:41:12 -0500 Subject: [PATCH 1/5] chore: cover deadline crossing autostart border --- coderd/autobuild/lifecycle_executor.go | 26 +---------- coderd/schedule/autostart.go | 30 +++++++++++++ coderd/schedule/autostop.go | 38 +++++++++++++++- coderd/schedule/autostop_test.go | 60 +++++++++++++++++++++++--- coderd/workspaceagents.go | 4 +- 5 files changed, 123 insertions(+), 35 deletions(-) create mode 100644 coderd/schedule/autostart.go diff --git a/coderd/autobuild/lifecycle_executor.go b/coderd/autobuild/lifecycle_executor.go index 6dfa8f4ea18ab..bf7be663c749d 100644 --- a/coderd/autobuild/lifecycle_executor.go +++ b/coderd/autobuild/lifecycle_executor.go @@ -20,7 +20,6 @@ import ( "github.com/coder/coder/v2/coderd/database/provisionerjobs" "github.com/coder/coder/v2/coderd/database/pubsub" "github.com/coder/coder/v2/coderd/schedule" - "github.com/coder/coder/v2/coderd/schedule/cron" "github.com/coder/coder/v2/coderd/wsbuilder" ) @@ -368,7 +367,7 @@ func isEligibleForAutostart(user database.User, ws database.Workspace, build dat return false } - nextTransition, allowed := NextAutostartSchedule(build.CreatedAt, ws.AutostartSchedule.String, templateSchedule) + nextTransition, allowed := schedule.NextAutostartSchedule(build.CreatedAt, ws.AutostartSchedule.String, templateSchedule) if !allowed { return false } @@ -377,29 +376,6 @@ func isEligibleForAutostart(user database.User, ws database.Workspace, build dat return !currentTick.Before(nextTransition) } -// NextAutostartSchedule takes the workspace and template schedule and returns the next autostart schedule -// after "at". The boolean returned is if the autostart should be allowed to start based on the template -// schedule. -func NextAutostartSchedule(at time.Time, wsSchedule string, templateSchedule schedule.TemplateScheduleOptions) (time.Time, bool) { - sched, err := cron.Weekly(wsSchedule) - if err != nil { - return time.Time{}, false - } - - // Round down to the nearest minute, as this is the finest granularity cron supports. - // Truncate is probably not necessary here, but doing it anyway to be sure. - nextTransition := sched.Next(at).Truncate(time.Minute) - - // The nextTransition is when the auto start should kick off. If it lands on a - // forbidden day, do not allow the auto start. We use the time location of the - // schedule to determine the weekday. So if "Saturday" is disallowed, the - // definition of "Saturday" depends on the location of the schedule. - zonedTransition := nextTransition.In(sched.Location()) - allowed := templateSchedule.AutostartRequirement.DaysMap()[zonedTransition.Weekday()] - - return zonedTransition, allowed -} - // isEligibleForAutostart returns true if the workspace should be autostopped. func isEligibleForAutostop(ws database.Workspace, build database.WorkspaceBuild, job database.ProvisionerJob, currentTick time.Time) bool { if job.JobStatus == database.ProvisionerJobStatusFailed { diff --git a/coderd/schedule/autostart.go b/coderd/schedule/autostart.go new file mode 100644 index 0000000000000..771240f309078 --- /dev/null +++ b/coderd/schedule/autostart.go @@ -0,0 +1,30 @@ +package schedule + +import ( + "time" + + "github.com/coder/coder/v2/coderd/schedule/cron" +) + +// NextAutostartSchedule takes the workspace and template schedule and returns the next autostart schedule +// after "at". The boolean returned is if the autostart should be allowed to start based on the template +// schedule. +func NextAutostartSchedule(at time.Time, wsSchedule string, templateSchedule TemplateScheduleOptions) (time.Time, bool) { + sched, err := cron.Weekly(wsSchedule) + if err != nil { + return time.Time{}, false + } + + // Round down to the nearest minute, as this is the finest granularity cron supports. + // Truncate is probably not necessary here, but doing it anyway to be sure. + nextTransition := sched.Next(at).Truncate(time.Minute) + + // The nextTransition is when the auto start should kick off. If it lands on a + // forbidden day, do not allow the auto start. We use the time location of the + // schedule to determine the weekday. So if "Saturday" is disallowed, the + // definition of "Saturday" depends on the location of the schedule. + zonedTransition := nextTransition.In(sched.Location()) + allowed := templateSchedule.AutostartRequirement.DaysMap()[zonedTransition.Weekday()] + + return zonedTransition, allowed +} diff --git a/coderd/schedule/autostop.go b/coderd/schedule/autostop.go index eb39ca763862a..4d34abc9d1b95 100644 --- a/coderd/schedule/autostop.go +++ b/coderd/schedule/autostop.go @@ -44,6 +44,11 @@ type CalculateAutostopParams struct { Database database.Store TemplateScheduleStore TemplateScheduleStore UserQuietHoursScheduleStore UserQuietHoursScheduleStore + // WorkspaceAutostart can be the empty string if no workspace autostart + // is configured. + // If configured, this is expected to be a cron weekly event parsable + // by autobuild.NextAutostartSchedule + WorkspaceAutostart string Now time.Time Workspace database.Workspace @@ -90,6 +95,14 @@ func CalculateAutostop(ctx context.Context, params CalculateAutostopParams) (Aut autostop AutostopTime ) + var ttl time.Duration + if workspace.Ttl.Valid { + // When the workspace is made it copies the template's TTL, and the user + // can unset it to disable it (unless the template has + // UserAutoStopEnabled set to false, see below). + ttl = time.Duration(workspace.Ttl.Int64) + } + if workspace.Ttl.Valid { // When the workspace is made it copies the template's TTL, and the user // can unset it to disable it (unless the template has @@ -104,9 +117,30 @@ func CalculateAutostop(ctx context.Context, params CalculateAutostopParams) (Aut if !templateSchedule.UserAutostopEnabled { // The user is not permitted to set their own TTL, so use the template // default. - autostop.Deadline = time.Time{} + ttl = 0 if templateSchedule.DefaultTTL > 0 { - autostop.Deadline = now.Add(templateSchedule.DefaultTTL) + ttl = templateSchedule.DefaultTTL + } + } + + if ttl > 0 { + // Only apply non-zero TTLs. + autostop.Deadline = now.Add(ttl) + if params.WorkspaceAutostart != "" { + // If the deadline passes the next autostart, we need to extend the deadline to + // autostart + deadline. ActivityBumpWorkspace already covers this case + // when extending the deadline. + // + // Situation this is solving. + // 1. User has workspace with auto-start at 9:00am, 12 hour auto-stop. + // 2. Coder stops workspace at 9pm + // 3. User starts workspace at 9:45pm. + // - The initial deadline is calculated to be 9:45am + // - This crosses the autostart deadline, so the deadline is extended to 9pm + nextAutostart, ok := NextAutostartSchedule(params.Now, params.WorkspaceAutostart, templateSchedule) + if ok && autostop.Deadline.After(nextAutostart) { + autostop.Deadline = nextAutostart.Add(ttl) + } } } diff --git a/coderd/schedule/autostop_test.go b/coderd/schedule/autostop_test.go index f932552812bd5..4159972b288c7 100644 --- a/coderd/schedule/autostop_test.go +++ b/coderd/schedule/autostop_test.go @@ -25,6 +25,12 @@ func TestCalculateAutoStop(t *testing.T) { now := time.Now() + chicago, err := time.LoadLocation("America/Chicago") + require.NoError(t, err, "loading chicago time location") + + // pastDateNight is 9:45pm on a wednesday + pastDateNight := time.Date(2024, 2, 14, 21, 45, 0, 0, chicago) + // Wednesday the 8th of February 2023 at midnight. This date was // specifically chosen as it doesn't fall on a applicable week for both // fortnightly and triweekly autostop requirements. @@ -70,8 +76,12 @@ func TestCalculateAutoStop(t *testing.T) { t.Log("saturdayMidnightAfterDstOut", saturdayMidnightAfterDstOut) cases := []struct { - name string - now time.Time + name string + now time.Time + + wsAutostart string + templateAutoStart schedule.TemplateAutostartRequirement + templateAllowAutostop bool templateDefaultTTL time.Duration templateAutostopRequirement schedule.TemplateAutostopRequirement @@ -364,6 +374,33 @@ func TestCalculateAutoStop(t *testing.T) { // expectedDeadline is copied from expectedMaxDeadline. expectedMaxDeadline: dstOutQuietHoursExpectedTime, }, + { + // A user expects this workspace to be online from 9am -> 9pm. + // So if a deadline is going to land in the middle of this range, + // we should bump it to the end. + // This is already done on `ActivityBumpWorkspace`, but that requires + // activity on the workspace. + name: "AutostopCrossAutostartBorder", + // Starting at 9:45pm, with the autostart at 9am. + now: pastDateNight, + templateAllowAutostop: false, + templateDefaultTTL: time.Hour * 12, + workspaceTTL: time.Hour * 12, + // At 9am every morning + wsAutostart: "CRON_TZ=America/Chicago 0 9 * * *", + + // No quiet hours + templateAutoStart: schedule.TemplateAutostartRequirement{ + // Just allow all days of the week + DaysOfWeek: 0b01111111, + }, + templateAutostopRequirement: schedule.TemplateAutostopRequirement{}, + userQuietHoursSchedule: "", + + expectedDeadline: time.Date(pastDateNight.Year(), pastDateNight.Month(), pastDateNight.Day()+1, 21, 0, 0, 0, chicago), + expectedMaxDeadline: time.Time{}, + errContains: "", + }, } for _, c := range cases { @@ -382,6 +419,7 @@ func TestCalculateAutoStop(t *testing.T) { UserAutostopEnabled: c.templateAllowAutostop, DefaultTTL: c.templateDefaultTTL, AutostopRequirement: c.templateAutostopRequirement, + AutostartRequirement: c.templateAutoStart, }, nil }, } @@ -433,11 +471,20 @@ func TestCalculateAutoStop(t *testing.T) { Valid: true, } } + + autostart := sql.NullString{} + if c.wsAutostart != "" { + autostart = sql.NullString{ + String: c.wsAutostart, + Valid: true, + } + } workspace := dbgen.Workspace(t, db, database.Workspace{ - TemplateID: template.ID, - OrganizationID: org.ID, - OwnerID: user.ID, - Ttl: workspaceTTL, + TemplateID: template.ID, + OrganizationID: org.ID, + OwnerID: user.ID, + Ttl: workspaceTTL, + AutostartSchedule: autostart, }) autostop, err := schedule.CalculateAutostop(ctx, schedule.CalculateAutostopParams{ @@ -446,6 +493,7 @@ func TestCalculateAutoStop(t *testing.T) { UserQuietHoursScheduleStore: userQuietHoursScheduleStore, Now: c.now, Workspace: workspace, + WorkspaceAutostart: c.wsAutostart, }) if c.errContains != "" { require.Error(t, err) diff --git a/coderd/workspaceagents.go b/coderd/workspaceagents.go index 4848fef38c138..b624c60249fa2 100644 --- a/coderd/workspaceagents.go +++ b/coderd/workspaceagents.go @@ -27,7 +27,6 @@ import ( "cdr.dev/slog" agentproto "github.com/coder/coder/v2/agent/proto" "github.com/coder/coder/v2/coderd/agentapi" - "github.com/coder/coder/v2/coderd/autobuild" "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/db2sdk" "github.com/coder/coder/v2/coderd/database/dbauthz" @@ -37,6 +36,7 @@ import ( "github.com/coder/coder/v2/coderd/httpmw" "github.com/coder/coder/v2/coderd/prometheusmetrics" "github.com/coder/coder/v2/coderd/rbac" + "github.com/coder/coder/v2/coderd/schedule" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/codersdk/agentsdk" "github.com/coder/coder/v2/codersdk/workspacesdk" @@ -1186,7 +1186,7 @@ func (api *API) workspaceAgentReportStats(rw http.ResponseWriter, r *http.Reques slog.Error(err), ) } else { - next, allowed := autobuild.NextAutostartSchedule(time.Now(), workspace.AutostartSchedule.String, templateSchedule) + next, allowed := schedule.NextAutostartSchedule(time.Now(), workspace.AutostartSchedule.String, templateSchedule) if allowed { nextAutostart = next } From bf7e698513bd263cbc1c4225fa4c23b2544cf744 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Tue, 30 Apr 2024 15:49:05 -0500 Subject: [PATCH 2/5] Add more unit tests --- coderd/schedule/autostop_test.go | 82 ++++++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) diff --git a/coderd/schedule/autostop_test.go b/coderd/schedule/autostop_test.go index 4159972b288c7..0c4c072438537 100644 --- a/coderd/schedule/autostop_test.go +++ b/coderd/schedule/autostop_test.go @@ -401,6 +401,88 @@ func TestCalculateAutoStop(t *testing.T) { expectedMaxDeadline: time.Time{}, errContains: "", }, + { + // Same as AutostopCrossAutostartBorder, but just misses the autostart. + name: "AutostopCrossMissAutostartBorder", + // Starting at 8:45pm, with the autostart at 9am. + now: time.Date(pastDateNight.Year(), pastDateNight.Month(), pastDateNight.Day(), 20, 30, 0, 0, chicago), + templateAllowAutostop: false, + templateDefaultTTL: time.Hour * 12, + workspaceTTL: time.Hour * 12, + // At 9am every morning + wsAutostart: "CRON_TZ=America/Chicago 0 9 * * *", + + // No quiet hours + templateAutoStart: schedule.TemplateAutostartRequirement{ + // Just allow all days of the week + DaysOfWeek: 0b01111111, + }, + templateAutostopRequirement: schedule.TemplateAutostopRequirement{}, + userQuietHoursSchedule: "", + + expectedDeadline: time.Date(pastDateNight.Year(), pastDateNight.Month(), pastDateNight.Day()+1, 8, 30, 0, 0, chicago), + expectedMaxDeadline: time.Time{}, + errContains: "", + }, + { + // Same as AutostopCrossAutostartBorderMaxEarlyDeadline with max deadline to limit it. + // The autostop deadline is before the autostart threshold. + name: "AutostopCrossAutostartBorderMaxEarlyDeadline", + // Starting at 9:45pm, with the autostart at 9am. + now: pastDateNight, + templateAllowAutostop: false, + templateDefaultTTL: time.Hour * 12, + workspaceTTL: time.Hour * 12, + // At 9am every morning + wsAutostart: "CRON_TZ=America/Chicago 0 9 * * *", + + // No quiet hours + templateAutoStart: schedule.TemplateAutostartRequirement{ + // Just allow all days of the week + DaysOfWeek: 0b01111111, + }, + templateAutostopRequirement: schedule.TemplateAutostopRequirement{ + // Autostop every day + DaysOfWeek: 0b01111111, + Weeks: 0, + }, + // 6am quiet hours + userQuietHoursSchedule: "CRON_TZ=America/Chicago 0 6 * * *", + + expectedDeadline: time.Date(pastDateNight.Year(), pastDateNight.Month(), pastDateNight.Day()+1, 6, 0, 0, 0, chicago), + expectedMaxDeadline: time.Date(pastDateNight.Year(), pastDateNight.Month(), pastDateNight.Day()+1, 6, 0, 0, 0, chicago), + errContains: "", + }, + { + // Same as AutostopCrossAutostartBorder with max deadline to limit it. + // The autostop deadline is after autostart threshold. + // So the deadline is > 12 hours, but stops at the max deadline. + name: "AutostopCrossAutostartBorderMaxDeadline", + // Starting at 9:45pm, with the autostart at 9am. + now: pastDateNight, + templateAllowAutostop: false, + templateDefaultTTL: time.Hour * 12, + workspaceTTL: time.Hour * 12, + // At 9am every morning + wsAutostart: "CRON_TZ=America/Chicago 0 9 * * *", + + // No quiet hours + templateAutoStart: schedule.TemplateAutostartRequirement{ + // Just allow all days of the week + DaysOfWeek: 0b01111111, + }, + templateAutostopRequirement: schedule.TemplateAutostopRequirement{ + // Autostop every day + DaysOfWeek: 0b01111111, + Weeks: 0, + }, + // 11am quiet hours, yea this is werid case. + userQuietHoursSchedule: "CRON_TZ=America/Chicago 0 11 * * *", + + expectedDeadline: time.Date(pastDateNight.Year(), pastDateNight.Month(), pastDateNight.Day()+1, 11, 0, 0, 0, chicago), + expectedMaxDeadline: time.Date(pastDateNight.Year(), pastDateNight.Month(), pastDateNight.Day()+1, 11, 0, 0, 0, chicago), + errContains: "", + }, } for _, c := range cases { From 44bd435aafa7d872fec931660687305f71b3e67a Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Tue, 30 Apr 2024 15:50:30 -0500 Subject: [PATCH 3/5] fixup compile error --- coderd/agentapi/stats.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/coderd/agentapi/stats.go b/coderd/agentapi/stats.go index a214fda3f15d9..a734c5ec4be4e 100644 --- a/coderd/agentapi/stats.go +++ b/coderd/agentapi/stats.go @@ -13,7 +13,6 @@ import ( "cdr.dev/slog" agentproto "github.com/coder/coder/v2/agent/proto" - "github.com/coder/coder/v2/coderd/autobuild" "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/dbtime" "github.com/coder/coder/v2/coderd/database/pubsub" @@ -84,7 +83,7 @@ func (a *StatsAPI) UpdateStats(ctx context.Context, req *agentproto.UpdateStatsR slog.Error(err), ) } else { - next, allowed := autobuild.NextAutostartSchedule(now, workspace.AutostartSchedule.String, templateSchedule) + next, allowed := schedule.NextAutostartSchedule(now, workspace.AutostartSchedule.String, templateSchedule) if allowed { nextAutostart = next } From a6b5a0bf102c9db9b9ee92ef47c5101f55969099 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Tue, 30 Apr 2024 15:52:21 -0500 Subject: [PATCH 4/5] Pass workspace autostart into CalculateAutoStop --- coderd/provisionerdserver/provisionerdserver.go | 2 ++ enterprise/coderd/schedule/template.go | 5 +++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/coderd/provisionerdserver/provisionerdserver.go b/coderd/provisionerdserver/provisionerdserver.go index bcfd4d65f5bf5..f6a8d6abe3a5e 100644 --- a/coderd/provisionerdserver/provisionerdserver.go +++ b/coderd/provisionerdserver/provisionerdserver.go @@ -1257,6 +1257,8 @@ func (s *server) CompleteJob(ctx context.Context, completed *proto.CompletedJob) UserQuietHoursScheduleStore: *s.UserQuietHoursScheduleStore.Load(), Now: now, Workspace: workspace, + // Allowed to be the empty string. + WorkspaceAutostart: workspace.AutostartSchedule.String, }) if err != nil { return xerrors.Errorf("calculate auto stop: %w", err) diff --git a/enterprise/coderd/schedule/template.go b/enterprise/coderd/schedule/template.go index ae5ec1110aa4d..1d246d344f026 100644 --- a/enterprise/coderd/schedule/template.go +++ b/enterprise/coderd/schedule/template.go @@ -263,8 +263,9 @@ func (s *EnterpriseTemplateScheduleStore) updateWorkspaceBuild(ctx context.Conte TemplateScheduleStore: s, UserQuietHoursScheduleStore: *s.UserQuietHoursScheduleStore.Load(), // Use the job completion time as the time we calculate autostop from. - Now: job.CompletedAt.Time, - Workspace: workspace, + Now: job.CompletedAt.Time, + Workspace: workspace, + WorkspaceAutostart: workspace.AutostartSchedule.String, }) if err != nil { return xerrors.Errorf("calculate new autostop for workspace %q: %w", workspace.ID, err) From 94872aa9a2b513f9b296ce4a95b8338e85091a0d Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Wed, 1 May 2024 10:12:55 -0500 Subject: [PATCH 5/5] function name remove Schedule --- coderd/agentapi/stats.go | 2 +- coderd/autobuild/lifecycle_executor.go | 2 +- coderd/schedule/autostart.go | 4 ++-- coderd/schedule/autostop.go | 4 ++-- coderd/workspaceagents.go | 2 +- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/coderd/agentapi/stats.go b/coderd/agentapi/stats.go index a734c5ec4be4e..fe5ccbe833022 100644 --- a/coderd/agentapi/stats.go +++ b/coderd/agentapi/stats.go @@ -83,7 +83,7 @@ func (a *StatsAPI) UpdateStats(ctx context.Context, req *agentproto.UpdateStatsR slog.Error(err), ) } else { - next, allowed := schedule.NextAutostartSchedule(now, workspace.AutostartSchedule.String, templateSchedule) + next, allowed := schedule.NextAutostart(now, workspace.AutostartSchedule.String, templateSchedule) if allowed { nextAutostart = next } diff --git a/coderd/autobuild/lifecycle_executor.go b/coderd/autobuild/lifecycle_executor.go index bf7be663c749d..e0d804328b2d3 100644 --- a/coderd/autobuild/lifecycle_executor.go +++ b/coderd/autobuild/lifecycle_executor.go @@ -367,7 +367,7 @@ func isEligibleForAutostart(user database.User, ws database.Workspace, build dat return false } - nextTransition, allowed := schedule.NextAutostartSchedule(build.CreatedAt, ws.AutostartSchedule.String, templateSchedule) + nextTransition, allowed := schedule.NextAutostart(build.CreatedAt, ws.AutostartSchedule.String, templateSchedule) if !allowed { return false } diff --git a/coderd/schedule/autostart.go b/coderd/schedule/autostart.go index 771240f309078..681bd5cfda718 100644 --- a/coderd/schedule/autostart.go +++ b/coderd/schedule/autostart.go @@ -6,10 +6,10 @@ import ( "github.com/coder/coder/v2/coderd/schedule/cron" ) -// NextAutostartSchedule takes the workspace and template schedule and returns the next autostart schedule +// NextAutostart takes the workspace and template schedule and returns the next autostart schedule // after "at". The boolean returned is if the autostart should be allowed to start based on the template // schedule. -func NextAutostartSchedule(at time.Time, wsSchedule string, templateSchedule TemplateScheduleOptions) (time.Time, bool) { +func NextAutostart(at time.Time, wsSchedule string, templateSchedule TemplateScheduleOptions) (time.Time, bool) { sched, err := cron.Weekly(wsSchedule) if err != nil { return time.Time{}, false diff --git a/coderd/schedule/autostop.go b/coderd/schedule/autostop.go index 4d34abc9d1b95..1651b3f64aa9c 100644 --- a/coderd/schedule/autostop.go +++ b/coderd/schedule/autostop.go @@ -47,7 +47,7 @@ type CalculateAutostopParams struct { // WorkspaceAutostart can be the empty string if no workspace autostart // is configured. // If configured, this is expected to be a cron weekly event parsable - // by autobuild.NextAutostartSchedule + // by autobuild.NextAutostart WorkspaceAutostart string Now time.Time @@ -137,7 +137,7 @@ func CalculateAutostop(ctx context.Context, params CalculateAutostopParams) (Aut // 3. User starts workspace at 9:45pm. // - The initial deadline is calculated to be 9:45am // - This crosses the autostart deadline, so the deadline is extended to 9pm - nextAutostart, ok := NextAutostartSchedule(params.Now, params.WorkspaceAutostart, templateSchedule) + nextAutostart, ok := NextAutostart(params.Now, params.WorkspaceAutostart, templateSchedule) if ok && autostop.Deadline.After(nextAutostart) { autostop.Deadline = nextAutostart.Add(ttl) } diff --git a/coderd/workspaceagents.go b/coderd/workspaceagents.go index b624c60249fa2..bf50971ea6cb8 100644 --- a/coderd/workspaceagents.go +++ b/coderd/workspaceagents.go @@ -1186,7 +1186,7 @@ func (api *API) workspaceAgentReportStats(rw http.ResponseWriter, r *http.Reques slog.Error(err), ) } else { - next, allowed := schedule.NextAutostartSchedule(time.Now(), workspace.AutostartSchedule.String, templateSchedule) + next, allowed := schedule.NextAutostart(time.Now(), workspace.AutostartSchedule.String, templateSchedule) if allowed { nextAutostart = next }