mirror of https://github.com/coder/coder.git
chore: cover deadline crossing autostart border on start (#13115)
When starting a workspace, if the deadline crosses an autostart boundary, the deadline is set to autostart + TTL. This copies the behavior in `ActivityBumpWorkspace`, but does not require activity.
This commit is contained in:
parent
71a03a8b1d
commit
845407fe7a
|
@ -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.NextAutostart(now, workspace.AutostartSchedule.String, templateSchedule)
|
||||
if allowed {
|
||||
nextAutostart = next
|
||||
}
|
||||
|
|
|
@ -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.NextAutostart(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 {
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -0,0 +1,30 @@
|
|||
package schedule
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/coder/coder/v2/coderd/schedule/cron"
|
||||
)
|
||||
|
||||
// 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 NextAutostart(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
|
||||
}
|
|
@ -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.NextAutostart
|
||||
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 := NextAutostart(params.Now, params.WorkspaceAutostart, templateSchedule)
|
||||
if ok && autostop.Deadline.After(nextAutostart) {
|
||||
autostop.Deadline = nextAutostart.Add(ttl)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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,115 @@ 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: "",
|
||||
},
|
||||
{
|
||||
// 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 {
|
||||
|
@ -382,6 +501,7 @@ func TestCalculateAutoStop(t *testing.T) {
|
|||
UserAutostopEnabled: c.templateAllowAutostop,
|
||||
DefaultTTL: c.templateDefaultTTL,
|
||||
AutostopRequirement: c.templateAutostopRequirement,
|
||||
AutostartRequirement: c.templateAutoStart,
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
|
@ -433,11 +553,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 +575,7 @@ func TestCalculateAutoStop(t *testing.T) {
|
|||
UserQuietHoursScheduleStore: userQuietHoursScheduleStore,
|
||||
Now: c.now,
|
||||
Workspace: workspace,
|
||||
WorkspaceAutostart: c.wsAutostart,
|
||||
})
|
||||
if c.errContains != "" {
|
||||
require.Error(t, err)
|
||||
|
|
|
@ -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.NextAutostart(time.Now(), workspace.AutostartSchedule.String, templateSchedule)
|
||||
if allowed {
|
||||
nextAutostart = next
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
Loading…
Reference in New Issue