coder/coderd/schedule/autostop.go

370 lines
14 KiB
Go

package schedule
import (
"context"
"time"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/trace"
"golang.org/x/xerrors"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/tracing"
)
const (
// autostopRequirementLeeway is the duration of time before a autostop
// requirement where we skip the requirement and fall back to the next
// scheduled stop. This avoids workspaces being stopped too soon.
//
// E.g. If the workspace is started within an hour of the quiet hours, we
// will skip the autostop requirement and use the next scheduled
// stop time instead.
autostopRequirementLeeway = 1 * time.Hour
// autostopRequirementBuffer is the duration of time we subtract from the
// time when calculating the next scheduled stop time. This avoids issues
// where autostart happens on the hour and the scheduled quiet hours are
// also on the hour.
//
// E.g. If the workspace is started at 12am (perhaps due to scheduled
// autostart) and the quiet hours is also 12am, the workspace will skip
// the day it's supposed to stop and use the next day instead. This is
// because getting the next cron schedule time will never include the
// time fed to the calculation (i.e. it's not inclusive). This happens
// because we always check for the next cron time by rounding down to
// midnight.
//
// This resolves that problem by subtracting 15 minutes from midnight
// when we check the next cron time.
autostopRequirementBuffer = -15 * time.Minute
)
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
}
type AutostopTime struct {
// Deadline is the time when the workspace will be stopped. The value can be
// bumped by user activity or manually by the user via the UI.
Deadline time.Time
// MaxDeadline is the maximum value for deadline.
MaxDeadline time.Time
}
// CalculateAutostop calculates the deadline and max_deadline for a workspace
// build.
//
// Deadline is the time when the workspace will be stopped, as long as it
// doesn't see any new activity (such as SSH, app requests, etc.). When activity
// is detected the deadline is bumped by the workspace's TTL (this only happens
// when activity is detected and more than 20% of the TTL has passed to save
// database queries).
//
// MaxDeadline is the maximum value for deadline. The deadline cannot be bumped
// past this value, so it denotes the absolute deadline that the workspace build
// must be stopped by. MaxDeadline is calculated using the template's "autostop
// requirement" settings and the user's "quiet hours" settings to pick a time
// outside of working hours.
//
// Deadline is a cost saving measure, while max deadline is a
// compliance/updating measure.
func CalculateAutostop(ctx context.Context, params CalculateAutostopParams) (AutostopTime, error) {
ctx, span := tracing.StartSpan(ctx,
trace.WithAttributes(attribute.String("coder.workspace_id", params.Workspace.ID.String())),
trace.WithAttributes(attribute.String("coder.template_id", params.Workspace.TemplateID.String())),
)
defer span.End()
defer span.End()
var (
db = params.Database
workspace = params.Workspace
now = params.Now
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
// UserAutoStopEnabled set to false, see below).
autostop.Deadline = now.Add(time.Duration(workspace.Ttl.Int64))
}
templateSchedule, err := params.TemplateScheduleStore.Get(ctx, db, workspace.TemplateID)
if err != nil {
return autostop, xerrors.Errorf("get template schedule options: %w", err)
}
if !templateSchedule.UserAutostopEnabled {
// The user is not permitted to set their own TTL, so use the template
// default.
ttl = 0
if templateSchedule.DefaultTTL > 0 {
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)
}
}
}
// Otherwise, use the autostop_requirement algorithm.
if templateSchedule.AutostopRequirement.DaysOfWeek != 0 {
// The template has a autostop requirement, so determine the max deadline
// of this workspace build.
// First, get the user's quiet hours schedule (this will return the
// default if the user has not set their own schedule).
userQuietHoursSchedule, err := params.UserQuietHoursScheduleStore.Get(ctx, db, workspace.OwnerID)
if err != nil {
return autostop, xerrors.Errorf("get user quiet hours schedule options: %w", err)
}
// If the schedule is nil, that means the deployment isn't entitled to
// use quiet hours. In this case, do not set a max deadline on the
// workspace.
if userQuietHoursSchedule.Schedule != nil {
loc := userQuietHoursSchedule.Schedule.Location()
now := now.In(loc)
// Add the leeway here so we avoid checking today's quiet hours if
// the workspace was started <1h before midnight.
startOfStopDay := truncateMidnight(now.Add(autostopRequirementLeeway))
// If the template schedule wants to only autostop on n-th weeks
// then change the startOfDay to be the Monday of the next
// applicable week.
if templateSchedule.AutostopRequirement.Weeks > 1 {
startOfStopDay, err = GetNextApplicableMondayOfNWeeks(startOfStopDay, templateSchedule.AutostopRequirement.Weeks)
if err != nil {
return autostop, xerrors.Errorf("determine start of stop week: %w", err)
}
}
// Determine if we should skip the first day because the schedule is
// too near or has already passed.
//
// Allow an hour of leeway (i.e. any workspaces started within an
// hour of the scheduled stop time will always bounce to the next
// stop window).
checkSchedule := userQuietHoursSchedule.Schedule.Next(startOfStopDay.Add(autostopRequirementBuffer))
if checkSchedule.Before(now.Add(autostopRequirementLeeway)) {
// Set the first stop day we try to tomorrow because today's
// schedule is too close to now or has already passed.
startOfStopDay = nextDayMidnight(startOfStopDay)
}
// Iterate from 0 to 7, check if the current startOfDay is in the
// autostop requirement. If it isn't then add a day and try again.
requirementDays := templateSchedule.AutostopRequirement.DaysMap()
for i := 0; i < len(DaysOfWeek)+1; i++ {
if i == len(DaysOfWeek) {
// We've wrapped, so somehow we couldn't find a day in the
// autostop requirement in the next week.
//
// This shouldn't be able to happen, as we've already
// checked that there is a day in the autostop requirement
// above with the
// `if templateSchedule.AutoStopRequirement.DaysOfWeek != 0`
// check.
//
// The eighth bit shouldn't be set, as we validate the
// bitmap in the enterprise TemplateScheduleStore.
return autostop, xerrors.New("could not find suitable day for template autostop requirement in the next 7 days")
}
if requirementDays[startOfStopDay.Weekday()] {
break
}
startOfStopDay = nextDayMidnight(startOfStopDay)
}
// If the startOfDay is within an hour of now, then we add an hour.
checkTime := startOfStopDay
if checkTime.Before(now.Add(time.Hour)) {
checkTime = now.Add(time.Hour)
} else {
// If it's not within an hour of now, subtract 15 minutes to
// give a little leeway. This prevents skipped stop events
// because autostart perfectly lines up with autostop.
checkTime = checkTime.Add(autostopRequirementBuffer)
}
// Get the next occurrence of the schedule.
autostop.MaxDeadline = userQuietHoursSchedule.Schedule.Next(checkTime)
if autostop.MaxDeadline.IsZero() {
return autostop, xerrors.Errorf("could not find next occurrence of template autostop requirement in user quiet hours schedule, checked from time %q", checkTime)
}
}
}
// If the workspace doesn't have a deadline or the max deadline is sooner
// than the workspace deadline, use the max deadline as the actual deadline.
if !autostop.MaxDeadline.IsZero() && (autostop.Deadline.IsZero() || autostop.MaxDeadline.Before(autostop.Deadline)) {
autostop.Deadline = autostop.MaxDeadline
}
if (!autostop.Deadline.IsZero() && autostop.Deadline.Before(now)) || (!autostop.MaxDeadline.IsZero() && autostop.MaxDeadline.Before(now)) {
// Something went wrong with the deadline calculation, so we should
// bail.
return autostop, xerrors.Errorf("deadline calculation error, computed deadline or max deadline is in the past for workspace build: deadline=%q maxDeadline=%q now=%q", autostop.Deadline, autostop.MaxDeadline, now)
}
return autostop, nil
}
// truncateMidnight truncates a time to midnight in the time object's timezone.
// t.Truncate(24 * time.Hour) truncates based on the internal time and doesn't
// factor daylight savings properly.
//
// See: https://github.com/golang/go/issues/10894
func truncateMidnight(t time.Time) time.Time {
yy, mm, dd := t.Date()
return time.Date(yy, mm, dd, 0, 0, 0, 0, t.Location())
}
// nextDayMidnight returns the next midnight in the time object's timezone.
func nextDayMidnight(t time.Time) time.Time {
yy, mm, dd := t.Date()
// time.Date will correctly normalize the date if it's past the end of the
// month. E.g. October 32nd will be November 1st.
dd++
return time.Date(yy, mm, dd, 0, 0, 0, 0, t.Location())
}
// WeeksSinceEpoch gets the weeks since the epoch for a given time. This is a
// 0-indexed number of weeks since the epoch (Monday).
//
// The timezone embedded in the time object is used to determine the epoch.
func WeeksSinceEpoch(now time.Time) (int64, error) {
epoch := TemplateAutostopRequirementEpoch(now.Location())
if now.Before(epoch) {
return 0, xerrors.New("coder server system clock is incorrect, cannot calculate template autostop requirement")
}
// This calculation needs to be done using YearDay, as dividing by the
// amount of hours is impacted by daylight savings. Even though daylight
// savings is usually only an hour difference, this calculation is used to
// get the current week number and could result in an entire week getting
// skipped if the calculation is off by an hour.
//
// Old naive algorithm: weeksSinceEpoch := int64(since.Hours() / (24 * 7))
// Get days since epoch. Start with a negative number of days, as we want to
// subtract the YearDay() of the epoch itself.
days := -epoch.YearDay()
for i := epoch.Year(); i < now.Year(); i++ {
startOfNextYear := time.Date(i+1, 1, 1, 0, 0, 0, 0, now.Location())
if startOfNextYear.Year() != i+1 {
return 0, xerrors.New("overflow calculating weeks since epoch")
}
endOfThisYear := startOfNextYear.AddDate(0, 0, -1)
if endOfThisYear.Year() != i {
return 0, xerrors.New("overflow calculating weeks since epoch")
}
days += endOfThisYear.YearDay()
}
// Add this year's days.
days += now.YearDay()
// Ensure that the number of days is positive.
if days < 0 {
return 0, xerrors.New("overflow calculating weeks since epoch")
}
// Divide by 7 to get the number of weeks.
weeksSinceEpoch := int64(days / 7)
return weeksSinceEpoch, nil
}
// GetMondayOfWeek gets the Monday (0:00) of the n-th week since epoch.
func GetMondayOfWeek(loc *time.Location, n int64) (time.Time, error) {
if n < 0 {
return time.Time{}, xerrors.New("weeks since epoch must be positive")
}
epoch := TemplateAutostopRequirementEpoch(loc)
monday := epoch.AddDate(0, 0, int(n*7))
y, m, d := monday.Date()
monday = time.Date(y, m, d, 0, 0, 0, 0, loc)
if monday.Weekday() != time.Monday {
// This condition should never be hit, but we have a check for it just
// in case.
return time.Time{}, xerrors.Errorf("calculated incorrect Monday for week %v since epoch (actual weekday %q)", n, monday.Weekday())
}
return monday, nil
}
// GetNextApplicableMondayOfNWeeks gets the next Monday (0:00) of the next week
// divisible by n since epoch. If the next applicable week is invalid for any
// reason, the week after will be used instead (up to 2 attempts).
//
// If the current week is divisible by n, then the provided time is returned as
// is.
//
// The timezone embedded in the time object is used to determine the epoch.
func GetNextApplicableMondayOfNWeeks(now time.Time, n int64) (time.Time, error) {
// Get the current week number.
weeksSinceEpoch, err := WeeksSinceEpoch(now)
if err != nil {
return time.Time{}, xerrors.Errorf("get current week number: %w", err)
}
// Get the next week divisible by n.
remainder := weeksSinceEpoch % n
week := weeksSinceEpoch + (n - remainder)
if remainder == 0 {
return now, nil
}
// Loop until we find a week that doesn't fail. This should never loop, but
// we account for failures just in case.
var lastErr error
for i := int64(0); i < 3; i++ {
monday, err := GetMondayOfWeek(now.Location(), week+i)
if err != nil {
lastErr = err
continue
}
return monday, nil
}
return time.Time{}, xerrors.Errorf("get next applicable Monday of %v weeks: %w", n, lastErr)
}