mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: add user quiet hours schedule and restart requirement feature flag (#8115)
This commit is contained in:
@@ -0,0 +1,332 @@
|
||||
package schedule
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/coderd/database"
|
||||
)
|
||||
|
||||
const (
|
||||
// restartRequirementLeeway is the duration of time before a restart
|
||||
// requirement where we skip the requirement and fall back to the next
|
||||
// scheduled restart. This avoids workspaces being restarted too soon.
|
||||
//
|
||||
// E.g. If the workspace is started within an hour of the quiet hours, we
|
||||
// will skip the restart requirement and use the next scheduled restart
|
||||
// requirement.
|
||||
restartRequirementLeeway = 1 * time.Hour
|
||||
|
||||
// restartRequirementBuffer is the duration of time we subtract from the
|
||||
// time when calculating the next scheduled restart 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.
|
||||
restartRequirementBuffer = -15 * time.Minute
|
||||
)
|
||||
|
||||
type CalculateAutostopParams struct {
|
||||
Database database.Store
|
||||
TemplateScheduleStore TemplateScheduleStore
|
||||
UserQuietHoursScheduleStore UserQuietHoursScheduleStore
|
||||
|
||||
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 "restart
|
||||
// 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) {
|
||||
var (
|
||||
db = params.Database
|
||||
workspace = params.Workspace
|
||||
now = params.Now
|
||||
|
||||
autostop AutostopTime
|
||||
)
|
||||
|
||||
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.
|
||||
autostop.Deadline = time.Time{}
|
||||
if templateSchedule.DefaultTTL > 0 {
|
||||
autostop.Deadline = now.Add(templateSchedule.DefaultTTL)
|
||||
}
|
||||
}
|
||||
|
||||
// Use the old algorithm for calculating max_deadline if the instance isn't
|
||||
// configured or entitled to use the new feature flag yet.
|
||||
// TODO(@dean): remove this once the feature flag is enabled for all
|
||||
if !templateSchedule.UseRestartRequirement && templateSchedule.MaxTTL > 0 {
|
||||
autostop.MaxDeadline = now.Add(templateSchedule.MaxTTL)
|
||||
}
|
||||
|
||||
// TODO(@dean): remove extra conditional
|
||||
if templateSchedule.UseRestartRequirement && templateSchedule.RestartRequirement.DaysOfWeek != 0 {
|
||||
// The template has a restart 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 or the default schedule has not been set. 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(restartRequirementLeeway))
|
||||
|
||||
// If the template schedule wants to only restart on n-th weeks then
|
||||
// change the startOfDay to be the Monday of the next applicable
|
||||
// week.
|
||||
if templateSchedule.RestartRequirement.Weeks > 1 {
|
||||
startOfStopDay, err = GetNextApplicableMondayOfNWeeks(startOfStopDay, templateSchedule.RestartRequirement.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(restartRequirementBuffer))
|
||||
if checkSchedule.Before(now.Add(restartRequirementLeeway)) {
|
||||
// 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
|
||||
// restart requirement. If it isn't then add a day and try again.
|
||||
requirementDays := templateSchedule.RestartRequirement.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
|
||||
// restart requirement in the next week.
|
||||
//
|
||||
// This shouldn't be able to happen, as we've already
|
||||
// checked that there is a day in the restart requirement
|
||||
// above with the
|
||||
// `if templateSchedule.RestartRequirement.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 restart 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(restartRequirementBuffer)
|
||||
}
|
||||
|
||||
// Get the next occurrence of the restart schedule.
|
||||
autostop.MaxDeadline = userQuietHoursSchedule.Schedule.Next(checkTime)
|
||||
if autostop.MaxDeadline.IsZero() {
|
||||
return autostop, xerrors.New("could not find next occurrence of template restart requirement in user quiet hours schedule")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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 := TemplateRestartRequirementEpoch(now.Location())
|
||||
if now.Before(epoch) {
|
||||
return 0, xerrors.New("coder server system clock is incorrect, cannot calculate template restart 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 := TemplateRestartRequirementEpoch(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)
|
||||
}
|
||||
@@ -0,0 +1,594 @@
|
||||
package schedule_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/coderd/database"
|
||||
"github.com/coder/coder/coderd/database/dbgen"
|
||||
"github.com/coder/coder/coderd/database/dbtestutil"
|
||||
"github.com/coder/coder/coderd/schedule"
|
||||
"github.com/coder/coder/testutil"
|
||||
)
|
||||
|
||||
func TestCalculateAutoStop(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Now()
|
||||
|
||||
// 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 restart requirements.
|
||||
wednesdayMidnightUTC := time.Date(2023, 2, 8, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
sydneyQuietHours := "CRON_TZ=Australia/Sydney 0 0 * * *"
|
||||
sydneyLoc, err := time.LoadLocation("Australia/Sydney")
|
||||
require.NoError(t, err)
|
||||
// 10pm on Friday the 10th of February 2023 in Sydney.
|
||||
fridayEveningSydney := time.Date(2023, 2, 10, 22, 0, 0, 0, sydneyLoc)
|
||||
// 12am on Saturday the 11th of February2023 in Sydney.
|
||||
saturdayMidnightSydney := time.Date(2023, 2, 11, 0, 0, 0, 0, sydneyLoc)
|
||||
|
||||
t.Log("now", now)
|
||||
t.Log("wednesdayMidnightUTC", wednesdayMidnightUTC)
|
||||
t.Log("fridayEveningSydney", fridayEveningSydney)
|
||||
t.Log("saturdayMidnightSydney", saturdayMidnightSydney)
|
||||
|
||||
dstIn := time.Date(2023, 10, 1, 2, 0, 0, 0, sydneyLoc) // 1 hour backward
|
||||
dstInQuietHours := "CRON_TZ=Australia/Sydney 30 2 * * *" // never
|
||||
// The expected behavior is that we will pick the next time that falls on
|
||||
// quiet hours after the DST transition. In this case, it will be the same
|
||||
// time the next day.
|
||||
dstInQuietHoursExpectedTime := time.Date(2023, 10, 2, 2, 30, 0, 0, sydneyLoc)
|
||||
beforeDstIn := time.Date(2023, 10, 1, 0, 0, 0, 0, sydneyLoc)
|
||||
saturdayMidnightAfterDstIn := time.Date(2023, 10, 7, 0, 0, 0, 0, sydneyLoc)
|
||||
|
||||
// Wednesday after DST starts.
|
||||
duringDst := time.Date(2023, 10, 4, 0, 0, 0, 0, sydneyLoc)
|
||||
saturdayMidnightAfterDuringDst := saturdayMidnightAfterDstIn
|
||||
|
||||
dstOut := time.Date(2024, 4, 7, 3, 0, 0, 0, sydneyLoc) // 1 hour forward
|
||||
dstOutQuietHours := "CRON_TZ=Australia/Sydney 30 3 * * *" // twice
|
||||
dstOutQuietHoursExpectedTime := time.Date(2024, 4, 7, 3, 30, 0, 0, sydneyLoc) // in reality, this is the first occurrence
|
||||
beforeDstOut := time.Date(2024, 4, 7, 0, 0, 0, 0, sydneyLoc)
|
||||
saturdayMidnightAfterDstOut := time.Date(2024, 4, 13, 0, 0, 0, 0, sydneyLoc)
|
||||
|
||||
t.Log("dstIn", dstIn)
|
||||
t.Log("beforeDstIn", beforeDstIn)
|
||||
t.Log("saturdayMidnightAfterDstIn", saturdayMidnightAfterDstIn)
|
||||
t.Log("dstOut", dstOut)
|
||||
t.Log("beforeDstOut", beforeDstOut)
|
||||
t.Log("saturdayMidnightAfterDstOut", saturdayMidnightAfterDstOut)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
now time.Time
|
||||
templateAllowAutostop bool
|
||||
templateDefaultTTL time.Duration
|
||||
// TODO(@dean): remove max_ttl tests
|
||||
useMaxTTL bool
|
||||
templateMaxTTL time.Duration
|
||||
templateRestartRequirement schedule.TemplateRestartRequirement
|
||||
userQuietHoursSchedule string
|
||||
// workspaceTTL is usually copied from the template's TTL when the
|
||||
// workspace is made, so it takes precedence unless
|
||||
// templateAllowAutostop is false.
|
||||
workspaceTTL time.Duration
|
||||
|
||||
// expectedDeadline is copied from expectedMaxDeadline if unset.
|
||||
expectedDeadline time.Time
|
||||
expectedMaxDeadline time.Time
|
||||
errContains string
|
||||
}{
|
||||
{
|
||||
name: "OK",
|
||||
now: now,
|
||||
templateAllowAutostop: true,
|
||||
templateDefaultTTL: 0,
|
||||
templateRestartRequirement: schedule.TemplateRestartRequirement{},
|
||||
workspaceTTL: 0,
|
||||
expectedDeadline: time.Time{},
|
||||
expectedMaxDeadline: time.Time{},
|
||||
},
|
||||
{
|
||||
name: "Delete",
|
||||
now: now,
|
||||
templateAllowAutostop: true,
|
||||
templateDefaultTTL: 0,
|
||||
templateRestartRequirement: schedule.TemplateRestartRequirement{},
|
||||
workspaceTTL: 0,
|
||||
expectedDeadline: time.Time{},
|
||||
expectedMaxDeadline: time.Time{},
|
||||
},
|
||||
{
|
||||
name: "WorkspaceTTL",
|
||||
now: now,
|
||||
templateAllowAutostop: true,
|
||||
templateDefaultTTL: 0,
|
||||
templateRestartRequirement: schedule.TemplateRestartRequirement{},
|
||||
workspaceTTL: time.Hour,
|
||||
expectedDeadline: now.Add(time.Hour),
|
||||
expectedMaxDeadline: time.Time{},
|
||||
},
|
||||
{
|
||||
name: "TemplateDefaultTTLIgnored",
|
||||
now: now,
|
||||
templateAllowAutostop: true,
|
||||
templateDefaultTTL: time.Hour,
|
||||
templateRestartRequirement: schedule.TemplateRestartRequirement{},
|
||||
workspaceTTL: 0,
|
||||
expectedDeadline: time.Time{},
|
||||
expectedMaxDeadline: time.Time{},
|
||||
},
|
||||
{
|
||||
name: "WorkspaceTTLOverridesTemplateDefaultTTL",
|
||||
now: now,
|
||||
templateAllowAutostop: true,
|
||||
templateDefaultTTL: 2 * time.Hour,
|
||||
templateRestartRequirement: schedule.TemplateRestartRequirement{},
|
||||
workspaceTTL: time.Hour,
|
||||
expectedDeadline: now.Add(time.Hour),
|
||||
expectedMaxDeadline: time.Time{},
|
||||
},
|
||||
{
|
||||
name: "TemplateBlockWorkspaceTTL",
|
||||
now: now,
|
||||
templateAllowAutostop: false,
|
||||
templateDefaultTTL: 3 * time.Hour,
|
||||
templateRestartRequirement: schedule.TemplateRestartRequirement{},
|
||||
workspaceTTL: 4 * time.Hour,
|
||||
expectedDeadline: now.Add(3 * time.Hour),
|
||||
expectedMaxDeadline: time.Time{},
|
||||
},
|
||||
{
|
||||
name: "TemplateRestartRequirement",
|
||||
now: wednesdayMidnightUTC,
|
||||
templateAllowAutostop: true,
|
||||
templateDefaultTTL: 0,
|
||||
userQuietHoursSchedule: sydneyQuietHours,
|
||||
templateRestartRequirement: schedule.TemplateRestartRequirement{
|
||||
DaysOfWeek: 0b00100000, // Saturday
|
||||
Weeks: 0, // weekly
|
||||
},
|
||||
workspaceTTL: 0,
|
||||
// expectedDeadline is copied from expectedMaxDeadline.
|
||||
expectedMaxDeadline: saturdayMidnightSydney.In(time.UTC),
|
||||
},
|
||||
{
|
||||
name: "TemplateRestartRequirement1HourSkip",
|
||||
now: saturdayMidnightSydney.Add(-59 * time.Minute),
|
||||
templateAllowAutostop: true,
|
||||
templateDefaultTTL: 0,
|
||||
userQuietHoursSchedule: sydneyQuietHours,
|
||||
templateRestartRequirement: schedule.TemplateRestartRequirement{
|
||||
DaysOfWeek: 0b00100000, // Saturday
|
||||
Weeks: 1, // 1 also means weekly
|
||||
},
|
||||
workspaceTTL: 0,
|
||||
// expectedDeadline is copied from expectedMaxDeadline.
|
||||
expectedMaxDeadline: saturdayMidnightSydney.Add(7 * 24 * time.Hour).In(time.UTC),
|
||||
},
|
||||
{
|
||||
// The next restart requirement should be skipped if the
|
||||
// workspace is started within 1 hour of it.
|
||||
name: "TemplateRestartRequirementDaily",
|
||||
now: fridayEveningSydney,
|
||||
templateAllowAutostop: true,
|
||||
templateDefaultTTL: 0,
|
||||
userQuietHoursSchedule: sydneyQuietHours,
|
||||
templateRestartRequirement: schedule.TemplateRestartRequirement{
|
||||
DaysOfWeek: 0b01111111, // daily
|
||||
Weeks: 0, // all weeks
|
||||
},
|
||||
workspaceTTL: 0,
|
||||
// expectedDeadline is copied from expectedMaxDeadline.
|
||||
expectedMaxDeadline: saturdayMidnightSydney.In(time.UTC),
|
||||
},
|
||||
{
|
||||
name: "TemplateRestartRequirementFortnightly/Skip",
|
||||
now: wednesdayMidnightUTC,
|
||||
templateAllowAutostop: true,
|
||||
templateDefaultTTL: 0,
|
||||
userQuietHoursSchedule: sydneyQuietHours,
|
||||
templateRestartRequirement: schedule.TemplateRestartRequirement{
|
||||
DaysOfWeek: 0b00100000, // Saturday
|
||||
Weeks: 2, // every 2 weeks
|
||||
},
|
||||
workspaceTTL: 0,
|
||||
// expectedDeadline is copied from expectedMaxDeadline.
|
||||
expectedMaxDeadline: saturdayMidnightSydney.AddDate(0, 0, 7).In(time.UTC),
|
||||
},
|
||||
{
|
||||
name: "TemplateRestartRequirementFortnightly/NoSkip",
|
||||
now: wednesdayMidnightUTC.AddDate(0, 0, 7),
|
||||
templateAllowAutostop: true,
|
||||
templateDefaultTTL: 0,
|
||||
userQuietHoursSchedule: sydneyQuietHours,
|
||||
templateRestartRequirement: schedule.TemplateRestartRequirement{
|
||||
DaysOfWeek: 0b00100000, // Saturday
|
||||
Weeks: 2, // every 2 weeks
|
||||
},
|
||||
workspaceTTL: 0,
|
||||
// expectedDeadline is copied from expectedMaxDeadline.
|
||||
expectedMaxDeadline: saturdayMidnightSydney.AddDate(0, 0, 7).In(time.UTC),
|
||||
},
|
||||
{
|
||||
name: "TemplateRestartRequirementTriweekly/Skip",
|
||||
now: wednesdayMidnightUTC,
|
||||
templateAllowAutostop: true,
|
||||
templateDefaultTTL: 0,
|
||||
userQuietHoursSchedule: sydneyQuietHours,
|
||||
templateRestartRequirement: schedule.TemplateRestartRequirement{
|
||||
DaysOfWeek: 0b00100000, // Saturday
|
||||
Weeks: 3, // every 3 weeks
|
||||
},
|
||||
workspaceTTL: 0,
|
||||
// expectedDeadline is copied from expectedMaxDeadline.
|
||||
// The next triweekly restart requirement happens next week
|
||||
// according to the epoch.
|
||||
expectedMaxDeadline: saturdayMidnightSydney.AddDate(0, 0, 7).In(time.UTC),
|
||||
},
|
||||
{
|
||||
name: "TemplateRestartRequirementTriweekly/NoSkip",
|
||||
now: wednesdayMidnightUTC.AddDate(0, 0, 7),
|
||||
templateAllowAutostop: true,
|
||||
templateDefaultTTL: 0,
|
||||
userQuietHoursSchedule: sydneyQuietHours,
|
||||
templateRestartRequirement: schedule.TemplateRestartRequirement{
|
||||
DaysOfWeek: 0b00100000, // Saturday
|
||||
Weeks: 3, // every 3 weeks
|
||||
},
|
||||
workspaceTTL: 0,
|
||||
// expectedDeadline is copied from expectedMaxDeadline.
|
||||
expectedMaxDeadline: saturdayMidnightSydney.AddDate(0, 0, 7).In(time.UTC),
|
||||
},
|
||||
{
|
||||
name: "TemplateRestartRequirementOverridesWorkspaceTTL",
|
||||
// now doesn't have to be UTC, but it helps us ensure that
|
||||
// timezones are compared correctly in this test.
|
||||
now: fridayEveningSydney.In(time.UTC),
|
||||
templateAllowAutostop: true,
|
||||
templateDefaultTTL: 0,
|
||||
userQuietHoursSchedule: sydneyQuietHours,
|
||||
templateRestartRequirement: schedule.TemplateRestartRequirement{
|
||||
DaysOfWeek: 0b00100000, // Saturday
|
||||
Weeks: 0, // weekly
|
||||
},
|
||||
workspaceTTL: 3 * time.Hour,
|
||||
// expectedDeadline is copied from expectedMaxDeadline.
|
||||
expectedMaxDeadline: saturdayMidnightSydney.In(time.UTC),
|
||||
},
|
||||
{
|
||||
name: "TemplateRestartRequirementOverridesTemplateDefaultTTL",
|
||||
now: fridayEveningSydney.In(time.UTC),
|
||||
templateAllowAutostop: true,
|
||||
templateDefaultTTL: 3 * time.Hour,
|
||||
userQuietHoursSchedule: sydneyQuietHours,
|
||||
templateRestartRequirement: schedule.TemplateRestartRequirement{
|
||||
DaysOfWeek: 0b00100000, // Saturday
|
||||
Weeks: 0, // weekly
|
||||
},
|
||||
workspaceTTL: 0,
|
||||
// expectedDeadline is copied from expectedMaxDeadline.
|
||||
expectedMaxDeadline: saturdayMidnightSydney.In(time.UTC),
|
||||
},
|
||||
{
|
||||
name: "TimeBeforeEpoch",
|
||||
// The epoch is 2023-01-02 in each timezone. We set the time to
|
||||
// 1 second before 11pm the previous day, as this is the latest time
|
||||
// we allow due to our 1h leeway logic.
|
||||
now: time.Date(2023, 1, 1, 22, 59, 59, 0, sydneyLoc),
|
||||
templateAllowAutostop: true,
|
||||
templateDefaultTTL: 0,
|
||||
userQuietHoursSchedule: sydneyQuietHours,
|
||||
templateRestartRequirement: schedule.TemplateRestartRequirement{
|
||||
DaysOfWeek: 0b00100000, // Saturday
|
||||
Weeks: 2, // every fortnight
|
||||
},
|
||||
workspaceTTL: 0,
|
||||
errContains: "coder server system clock is incorrect",
|
||||
},
|
||||
{
|
||||
name: "DaylightSavings/OK",
|
||||
now: duringDst,
|
||||
templateAllowAutostop: true,
|
||||
templateDefaultTTL: 0,
|
||||
userQuietHoursSchedule: sydneyQuietHours,
|
||||
templateRestartRequirement: schedule.TemplateRestartRequirement{
|
||||
DaysOfWeek: 0b00100000, // Saturday
|
||||
Weeks: 1, // weekly
|
||||
},
|
||||
workspaceTTL: 0,
|
||||
// expectedDeadline is copied from expectedMaxDeadline.
|
||||
expectedMaxDeadline: saturdayMidnightAfterDuringDst,
|
||||
},
|
||||
{
|
||||
name: "DaylightSavings/SwitchMidWeek/In",
|
||||
now: beforeDstIn,
|
||||
templateAllowAutostop: true,
|
||||
templateDefaultTTL: 0,
|
||||
userQuietHoursSchedule: sydneyQuietHours,
|
||||
templateRestartRequirement: schedule.TemplateRestartRequirement{
|
||||
DaysOfWeek: 0b00100000, // Saturday
|
||||
Weeks: 1, // weekly
|
||||
},
|
||||
workspaceTTL: 0,
|
||||
// expectedDeadline is copied from expectedMaxDeadline.
|
||||
expectedMaxDeadline: saturdayMidnightAfterDstIn,
|
||||
},
|
||||
{
|
||||
name: "DaylightSavings/SwitchMidWeek/Out",
|
||||
now: beforeDstOut,
|
||||
templateAllowAutostop: true,
|
||||
templateDefaultTTL: 0,
|
||||
userQuietHoursSchedule: sydneyQuietHours,
|
||||
templateRestartRequirement: schedule.TemplateRestartRequirement{
|
||||
DaysOfWeek: 0b00100000, // Saturday
|
||||
Weeks: 1, // weekly
|
||||
},
|
||||
workspaceTTL: 0,
|
||||
// expectedDeadline is copied from expectedMaxDeadline.
|
||||
expectedMaxDeadline: saturdayMidnightAfterDstOut,
|
||||
},
|
||||
{
|
||||
name: "DaylightSavings/QuietHoursFallsOnDstSwitch/In",
|
||||
now: beforeDstIn.Add(-24 * time.Hour),
|
||||
templateAllowAutostop: true,
|
||||
templateDefaultTTL: 0,
|
||||
userQuietHoursSchedule: dstInQuietHours,
|
||||
templateRestartRequirement: schedule.TemplateRestartRequirement{
|
||||
DaysOfWeek: 0b01000000, // Sunday
|
||||
Weeks: 1, // weekly
|
||||
},
|
||||
workspaceTTL: 0,
|
||||
// expectedDeadline is copied from expectedMaxDeadline.
|
||||
expectedMaxDeadline: dstInQuietHoursExpectedTime,
|
||||
},
|
||||
{
|
||||
name: "DaylightSavings/QuietHoursFallsOnDstSwitch/Out",
|
||||
now: beforeDstOut.Add(-24 * time.Hour),
|
||||
templateAllowAutostop: true,
|
||||
templateDefaultTTL: 0,
|
||||
userQuietHoursSchedule: dstOutQuietHours,
|
||||
templateRestartRequirement: schedule.TemplateRestartRequirement{
|
||||
DaysOfWeek: 0b01000000, // Sunday
|
||||
Weeks: 1, // weekly
|
||||
},
|
||||
workspaceTTL: 0,
|
||||
// expectedDeadline is copied from expectedMaxDeadline.
|
||||
expectedMaxDeadline: dstOutQuietHoursExpectedTime,
|
||||
},
|
||||
|
||||
// TODO(@dean): remove max_ttl tests
|
||||
{
|
||||
name: "RestartRequirementIgnoresMaxTTL",
|
||||
now: fridayEveningSydney.In(time.UTC),
|
||||
templateAllowAutostop: false,
|
||||
templateDefaultTTL: 0,
|
||||
useMaxTTL: false,
|
||||
templateMaxTTL: time.Hour, // should be ignored
|
||||
userQuietHoursSchedule: sydneyQuietHours,
|
||||
templateRestartRequirement: schedule.TemplateRestartRequirement{
|
||||
DaysOfWeek: 0b00100000, // Saturday
|
||||
Weeks: 0, // weekly
|
||||
},
|
||||
workspaceTTL: 0,
|
||||
// expectedDeadline is copied from expectedMaxDeadline.
|
||||
expectedMaxDeadline: saturdayMidnightSydney.In(time.UTC),
|
||||
},
|
||||
{
|
||||
name: "MaxTTLIgnoresRestartRequirement",
|
||||
now: fridayEveningSydney.In(time.UTC),
|
||||
templateAllowAutostop: false,
|
||||
templateDefaultTTL: 0,
|
||||
useMaxTTL: true,
|
||||
templateMaxTTL: time.Hour, // should NOT be ignored
|
||||
userQuietHoursSchedule: sydneyQuietHours,
|
||||
templateRestartRequirement: schedule.TemplateRestartRequirement{
|
||||
DaysOfWeek: 0b00100000, // Saturday
|
||||
Weeks: 0, // weekly
|
||||
},
|
||||
workspaceTTL: 0,
|
||||
// expectedDeadline is copied from expectedMaxDeadline.
|
||||
expectedMaxDeadline: fridayEveningSydney.Add(time.Hour).In(time.UTC),
|
||||
},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
c := c
|
||||
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db, _ := dbtestutil.NewDB(t)
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
|
||||
templateScheduleStore := schedule.MockTemplateScheduleStore{
|
||||
GetFn: func(_ context.Context, _ database.Store, _ uuid.UUID) (schedule.TemplateScheduleOptions, error) {
|
||||
return schedule.TemplateScheduleOptions{
|
||||
UserAutostartEnabled: false,
|
||||
UserAutostopEnabled: c.templateAllowAutostop,
|
||||
DefaultTTL: c.templateDefaultTTL,
|
||||
MaxTTL: c.templateMaxTTL,
|
||||
UseRestartRequirement: !c.useMaxTTL,
|
||||
RestartRequirement: c.templateRestartRequirement,
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
|
||||
userQuietHoursScheduleStore := schedule.MockUserQuietHoursScheduleStore{
|
||||
GetFn: func(_ context.Context, _ database.Store, _ uuid.UUID) (schedule.UserQuietHoursScheduleOptions, error) {
|
||||
if c.userQuietHoursSchedule == "" {
|
||||
return schedule.UserQuietHoursScheduleOptions{
|
||||
Schedule: nil,
|
||||
}, nil
|
||||
}
|
||||
|
||||
sched, err := schedule.Daily(c.userQuietHoursSchedule)
|
||||
if !assert.NoError(t, err) {
|
||||
return schedule.UserQuietHoursScheduleOptions{}, err
|
||||
}
|
||||
|
||||
return schedule.UserQuietHoursScheduleOptions{
|
||||
Schedule: sched,
|
||||
UserSet: false,
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
|
||||
org := dbgen.Organization(t, db, database.Organization{})
|
||||
user := dbgen.User(t, db, database.User{
|
||||
QuietHoursSchedule: c.userQuietHoursSchedule,
|
||||
})
|
||||
template := dbgen.Template(t, db, database.Template{
|
||||
Name: "template",
|
||||
Provisioner: database.ProvisionerTypeEcho,
|
||||
OrganizationID: org.ID,
|
||||
CreatedBy: user.ID,
|
||||
})
|
||||
err := db.UpdateTemplateScheduleByID(ctx, database.UpdateTemplateScheduleByIDParams{
|
||||
ID: template.ID,
|
||||
UpdatedAt: database.Now(),
|
||||
AllowUserAutostart: c.templateAllowAutostop,
|
||||
RestartRequirementDaysOfWeek: int16(c.templateRestartRequirement.DaysOfWeek),
|
||||
RestartRequirementWeeks: c.templateRestartRequirement.Weeks,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
template, err = db.GetTemplateByID(ctx, template.ID)
|
||||
require.NoError(t, err)
|
||||
workspaceTTL := sql.NullInt64{}
|
||||
if c.workspaceTTL != 0 {
|
||||
workspaceTTL = sql.NullInt64{
|
||||
Int64: int64(c.workspaceTTL),
|
||||
Valid: true,
|
||||
}
|
||||
}
|
||||
workspace := dbgen.Workspace(t, db, database.Workspace{
|
||||
TemplateID: template.ID,
|
||||
OrganizationID: org.ID,
|
||||
OwnerID: user.ID,
|
||||
Ttl: workspaceTTL,
|
||||
})
|
||||
|
||||
autostop, err := schedule.CalculateAutostop(ctx, schedule.CalculateAutostopParams{
|
||||
Database: db,
|
||||
TemplateScheduleStore: templateScheduleStore,
|
||||
UserQuietHoursScheduleStore: userQuietHoursScheduleStore,
|
||||
Now: c.now,
|
||||
Workspace: workspace,
|
||||
})
|
||||
if c.errContains != "" {
|
||||
require.Error(t, err)
|
||||
require.ErrorContains(t, err, c.errContains)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
|
||||
// If the max deadline is set, the deadline should also be set.
|
||||
// Default to the max deadline if the deadline is not set.
|
||||
if c.expectedDeadline.IsZero() {
|
||||
c.expectedDeadline = c.expectedMaxDeadline
|
||||
}
|
||||
|
||||
if c.expectedDeadline.IsZero() {
|
||||
require.True(t, autostop.Deadline.IsZero())
|
||||
} else {
|
||||
require.WithinDuration(t, c.expectedDeadline, autostop.Deadline, 15*time.Second, "deadline does not match expected")
|
||||
}
|
||||
if c.expectedMaxDeadline.IsZero() {
|
||||
require.True(t, autostop.MaxDeadline.IsZero())
|
||||
} else {
|
||||
require.WithinDuration(t, c.expectedMaxDeadline, autostop.MaxDeadline, 15*time.Second, "max deadline does not match expected")
|
||||
require.GreaterOrEqual(t, autostop.MaxDeadline.Unix(), autostop.Deadline.Unix(), "max deadline is smaller than deadline")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindWeek(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
timezones := []string{
|
||||
"UTC",
|
||||
"America/Los_Angeles",
|
||||
"America/New_York",
|
||||
"Europe/Dublin",
|
||||
"Europe/London",
|
||||
"Europe/Paris",
|
||||
"Asia/Kolkata", // India (UTC+5:30)
|
||||
"Asia/Tokyo",
|
||||
"Australia/Sydney",
|
||||
"Australia/Brisbane",
|
||||
}
|
||||
|
||||
for _, tz := range timezones {
|
||||
tz := tz
|
||||
t.Run("Loc/"+tz, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
loc, err := time.LoadLocation(tz)
|
||||
require.NoError(t, err)
|
||||
|
||||
now := time.Now().In(loc)
|
||||
currentWeek, err := schedule.WeeksSinceEpoch(now)
|
||||
require.NoError(t, err)
|
||||
|
||||
diffMonday := now.Weekday() - time.Monday
|
||||
if now.Weekday() == time.Sunday {
|
||||
// Sunday is 0, but Monday is the first day of the week in the
|
||||
// code.
|
||||
diffMonday = 6
|
||||
}
|
||||
currentWeekMondayExpected := now.AddDate(0, 0, -int(diffMonday))
|
||||
require.Equal(t, time.Monday, currentWeekMondayExpected.Weekday())
|
||||
y, m, d := currentWeekMondayExpected.Date()
|
||||
// Change to midnight.
|
||||
currentWeekMondayExpected = time.Date(y, m, d, 0, 0, 0, 0, loc)
|
||||
|
||||
currentWeekMonday, err := schedule.GetMondayOfWeek(now.Location(), currentWeek)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, time.Monday, currentWeekMonday.Weekday())
|
||||
require.Equal(t, currentWeekMondayExpected, currentWeekMonday)
|
||||
|
||||
t.Log("now", now)
|
||||
t.Log("currentWeek", currentWeek)
|
||||
t.Log("currentMonday", currentWeekMonday)
|
||||
|
||||
// Loop through every single Monday and Sunday for the next 100
|
||||
// years and make sure the week calculations are correct.
|
||||
for i := int64(1); i < 52*100; i++ {
|
||||
msg := fmt.Sprintf("week %d", i)
|
||||
|
||||
monday := currentWeekMonday.AddDate(0, 0, int(i*7))
|
||||
y, m, d := monday.Date()
|
||||
monday = time.Date(y, m, d, 0, 0, 0, 0, loc)
|
||||
require.Equal(t, monday.Weekday(), time.Monday, msg)
|
||||
t.Log(msg, "monday", monday)
|
||||
|
||||
week, err := schedule.WeeksSinceEpoch(monday)
|
||||
require.NoError(t, err, msg)
|
||||
require.Equal(t, currentWeek+i, week, msg)
|
||||
|
||||
gotMonday, err := schedule.GetMondayOfWeek(monday.Location(), week)
|
||||
require.NoError(t, err, msg)
|
||||
require.Equal(t, monday, gotMonday, msg)
|
||||
|
||||
// Check that we get the same week number for late Sunday.
|
||||
sunday := time.Date(y, m, d+6, 23, 59, 59, 0, loc)
|
||||
require.Equal(t, sunday.Weekday(), time.Sunday, msg)
|
||||
t.Log(msg, "sunday", sunday)
|
||||
|
||||
week, err = schedule.WeeksSinceEpoch(sunday)
|
||||
require.NoError(t, err, msg)
|
||||
require.Equal(t, currentWeek+i, week, msg)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+47
-1
@@ -42,6 +42,36 @@ func Weekly(raw string) (*Schedule, error) {
|
||||
return nil, xerrors.Errorf("validate weekly schedule: %w", err)
|
||||
}
|
||||
|
||||
return parse(raw)
|
||||
}
|
||||
|
||||
// Daily parses a Schedule from spec scoped to a recurring daily event.
|
||||
// Spec consists of the following space-delimited fields, in the following order:
|
||||
// - timezone e.g. CRON_TZ=US/Central (optional)
|
||||
// - minutes of hour e.g. 30 (required)
|
||||
// - hour of day e.g. 9 (required)
|
||||
// - day of month (must be *)
|
||||
// - month (must be *)
|
||||
// - day of week (must be *)
|
||||
//
|
||||
// Example Usage:
|
||||
//
|
||||
// local_sched, _ := schedule.Weekly("59 23 * * *")
|
||||
// fmt.Println(sched.Next(time.Now().Format(time.RFC3339)))
|
||||
// // Output: 2022-04-04T23:59:00Z
|
||||
//
|
||||
// us_sched, _ := schedule.Weekly("CRON_TZ=US/Central 30 9 * * *")
|
||||
// fmt.Println(sched.Next(time.Now()).Format(time.RFC3339))
|
||||
// // Output: 2022-04-04T14:30:00Z
|
||||
func Daily(raw string) (*Schedule, error) {
|
||||
if err := validateDailySpec(raw); err != nil {
|
||||
return nil, xerrors.Errorf("validate daily schedule: %w", err)
|
||||
}
|
||||
|
||||
return parse(raw)
|
||||
}
|
||||
|
||||
func parse(raw string) (*Schedule, error) {
|
||||
// If schedule does not specify a timezone, default to UTC. Otherwise,
|
||||
// the library will default to time.Local which we want to avoid.
|
||||
if !strings.HasPrefix(raw, "CRON_TZ=") {
|
||||
@@ -187,7 +217,23 @@ func validateWeeklySpec(spec string) error {
|
||||
parts = parts[1:]
|
||||
}
|
||||
if parts[2] != "*" || parts[3] != "*" {
|
||||
return xerrors.Errorf("expected month and dom to be *")
|
||||
return xerrors.Errorf("expected day-of-month and month to be *")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateDailySpec ensures that the day-of-month, month and day-of-week
|
||||
// options of spec are all set to *
|
||||
func validateDailySpec(spec string) error {
|
||||
parts := strings.Fields(spec)
|
||||
if len(parts) < 5 {
|
||||
return xerrors.Errorf("expected schedule to consist of 5 fields with an optional CRON_TZ=<timezone> prefix")
|
||||
}
|
||||
if len(parts) == 6 {
|
||||
parts = parts[1:]
|
||||
}
|
||||
if parts[2] != "*" || parts[3] != "*" || parts[4] != "*" {
|
||||
return xerrors.Errorf("expected day-of-month, month and day-of-week to be *")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -129,14 +129,14 @@ func Test_Weekly(t *testing.T) {
|
||||
spec: "30 9 1 1 1-5",
|
||||
at: time.Time{},
|
||||
expectedNext: time.Time{},
|
||||
expectedError: "validate weekly schedule: expected month and dom to be *",
|
||||
expectedError: "validate weekly schedule: expected day-of-month and month to be *",
|
||||
},
|
||||
{
|
||||
name: "valid schedule with 5 fields and timezone but month and dom not set to *",
|
||||
spec: "CRON_TZ=Europe/Dublin 30 9 1 1 1-5",
|
||||
at: time.Time{},
|
||||
expectedNext: time.Time{},
|
||||
expectedError: "validate weekly schedule: expected month and dom to be *",
|
||||
expectedError: "validate weekly schedule: expected day-of-month and month to be *",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
+27
-4
@@ -15,18 +15,41 @@ type MockTemplateScheduleStore struct {
|
||||
|
||||
var _ TemplateScheduleStore = MockTemplateScheduleStore{}
|
||||
|
||||
func (m MockTemplateScheduleStore) GetTemplateScheduleOptions(ctx context.Context, db database.Store, templateID uuid.UUID) (TemplateScheduleOptions, error) {
|
||||
func (m MockTemplateScheduleStore) Get(ctx context.Context, db database.Store, templateID uuid.UUID) (TemplateScheduleOptions, error) {
|
||||
if m.GetFn != nil {
|
||||
return m.GetFn(ctx, db, templateID)
|
||||
}
|
||||
|
||||
return NewAGPLTemplateScheduleStore().GetTemplateScheduleOptions(ctx, db, templateID)
|
||||
return NewAGPLTemplateScheduleStore().Get(ctx, db, templateID)
|
||||
}
|
||||
|
||||
func (m MockTemplateScheduleStore) SetTemplateScheduleOptions(ctx context.Context, db database.Store, template database.Template, options TemplateScheduleOptions) (database.Template, error) {
|
||||
func (m MockTemplateScheduleStore) Set(ctx context.Context, db database.Store, template database.Template, options TemplateScheduleOptions) (database.Template, error) {
|
||||
if m.SetFn != nil {
|
||||
return m.SetFn(ctx, db, template, options)
|
||||
}
|
||||
|
||||
return NewAGPLTemplateScheduleStore().SetTemplateScheduleOptions(ctx, db, template, options)
|
||||
return NewAGPLTemplateScheduleStore().Set(ctx, db, template, options)
|
||||
}
|
||||
|
||||
type MockUserQuietHoursScheduleStore struct {
|
||||
GetFn func(ctx context.Context, db database.Store, userID uuid.UUID) (UserQuietHoursScheduleOptions, error)
|
||||
SetFn func(ctx context.Context, db database.Store, userID uuid.UUID, schedule string) (UserQuietHoursScheduleOptions, error)
|
||||
}
|
||||
|
||||
var _ UserQuietHoursScheduleStore = MockUserQuietHoursScheduleStore{}
|
||||
|
||||
func (m MockUserQuietHoursScheduleStore) Get(ctx context.Context, db database.Store, userID uuid.UUID) (UserQuietHoursScheduleOptions, error) {
|
||||
if m.GetFn != nil {
|
||||
return m.GetFn(ctx, db, userID)
|
||||
}
|
||||
|
||||
return NewAGPLUserQuietHoursScheduleStore().Get(ctx, db, userID)
|
||||
}
|
||||
|
||||
func (m MockUserQuietHoursScheduleStore) Set(ctx context.Context, db database.Store, userID uuid.UUID, schedule string) (UserQuietHoursScheduleOptions, error) {
|
||||
if m.SetFn != nil {
|
||||
return m.SetFn(ctx, db, userID, schedule)
|
||||
}
|
||||
|
||||
return NewAGPLUserQuietHoursScheduleStore().Set(ctx, db, userID, schedule)
|
||||
}
|
||||
|
||||
+109
-24
@@ -4,35 +4,113 @@ import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/coderd/database"
|
||||
)
|
||||
|
||||
const MaxTemplateRestartRequirementWeeks = 16
|
||||
|
||||
func TemplateRestartRequirementEpoch(loc *time.Location) time.Time {
|
||||
// The "first week" starts on January 2nd, 2023, which is the first Monday
|
||||
// of 2023. All other weeks are counted using modulo arithmetic from that
|
||||
// date.
|
||||
return time.Date(2023, time.January, 2, 0, 0, 0, 0, loc)
|
||||
}
|
||||
|
||||
// DaysOfWeek intentionally starts on Monday as opposed to Sunday so the weekend
|
||||
// days are contiguous in the bitmap. This matters greatly when doing restarts
|
||||
// every second week or more to avoid workspaces restarting "at the start" of
|
||||
// the week rather than "at the end" of the week.
|
||||
var DaysOfWeek = []time.Weekday{
|
||||
time.Monday,
|
||||
time.Tuesday,
|
||||
time.Wednesday,
|
||||
time.Thursday,
|
||||
time.Friday,
|
||||
time.Saturday,
|
||||
time.Sunday,
|
||||
}
|
||||
|
||||
type TemplateRestartRequirement struct {
|
||||
// DaysOfWeek is a bitmap of which days of the week the workspace must be
|
||||
// restarted. If fully zero, the workspace is not required to be restarted
|
||||
// ever.
|
||||
//
|
||||
// First bit is Monday, ..., seventh bit is Sunday, eighth bit is unused.
|
||||
DaysOfWeek uint8
|
||||
// Weeks is the amount of weeks between restarts. If 0 or 1, the workspace
|
||||
// is restarted weekly in accordance with DaysOfWeek. If 2, the workspace is
|
||||
// restarted every other week. And so forth.
|
||||
//
|
||||
// The limit for this value is 16, which is roughly 4 months.
|
||||
//
|
||||
// The "first week" starts on January 2nd, 2023, which is the first Monday
|
||||
// of 2023. All other weeks are counted using modulo arithmetic from that
|
||||
// date.
|
||||
Weeks int64
|
||||
}
|
||||
|
||||
// DaysMap returns a map of the days of the week that the workspace must be
|
||||
// restarted.
|
||||
func (r TemplateRestartRequirement) DaysMap() map[time.Weekday]bool {
|
||||
days := make(map[time.Weekday]bool)
|
||||
for i, day := range DaysOfWeek {
|
||||
days[day] = r.DaysOfWeek&(1<<uint(i)) != 0
|
||||
}
|
||||
return days
|
||||
}
|
||||
|
||||
// VerifyTemplateRestartRequirement returns an error if the restart requirement
|
||||
// is invalid.
|
||||
func VerifyTemplateRestartRequirement(days uint8, weeks int64) error {
|
||||
if days&0b10000000 != 0 {
|
||||
return xerrors.New("invalid restart requirement days, last bit is set")
|
||||
}
|
||||
if days > 0b11111111 {
|
||||
return xerrors.New("invalid restart requirement days, too large")
|
||||
}
|
||||
if weeks < 0 {
|
||||
return xerrors.New("invalid restart requirement weeks, negative")
|
||||
}
|
||||
if weeks > MaxTemplateRestartRequirementWeeks {
|
||||
return xerrors.New("invalid restart requirement weeks, too large")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type TemplateScheduleOptions struct {
|
||||
UserAutostartEnabled bool `json:"user_autostart_enabled"`
|
||||
UserAutostopEnabled bool `json:"user_autostop_enabled"`
|
||||
DefaultTTL time.Duration `json:"default_ttl"`
|
||||
// If MaxTTL is set, the workspace must be stopped before this time or it
|
||||
// will be stopped automatically.
|
||||
//
|
||||
// If set, users cannot disable automatic workspace shutdown.
|
||||
// TODO(@dean): remove MaxTTL once restart_requirement is matured and the
|
||||
// default
|
||||
MaxTTL time.Duration `json:"max_ttl"`
|
||||
// FailureTTL dictates the duration after which failed workspaces will be stopped automatically.
|
||||
// UseRestartRequirement dictates whether the restart requirement should be
|
||||
// used instead of MaxTTL. This is governed by the feature flag and
|
||||
// licensing.
|
||||
// TODO(@dean): remove this when we remove max_tll
|
||||
UseRestartRequirement bool
|
||||
// RestartRequirement dictates when the workspace must be restarted. This
|
||||
// used to be handled by MaxTTL.
|
||||
RestartRequirement TemplateRestartRequirement `json:"restart_requirement"`
|
||||
// FailureTTL dictates the duration after which failed workspaces will be
|
||||
// stopped automatically.
|
||||
FailureTTL time.Duration `json:"failure_ttl"`
|
||||
// InactivityTTL dictates the duration after which inactive workspaces will be locked.
|
||||
// InactivityTTL dictates the duration after which inactive workspaces will
|
||||
// be locked.
|
||||
InactivityTTL time.Duration `json:"inactivity_ttl"`
|
||||
// LockedTTL dictates the duration after which locked workspaces will be permanently deleted.
|
||||
// LockedTTL dictates the duration after which locked workspaces will be
|
||||
// permanently deleted.
|
||||
LockedTTL time.Duration `json:"locked_ttl"`
|
||||
}
|
||||
|
||||
// TemplateScheduleStore provides an interface for retrieving template
|
||||
// scheduling options set by the template/site admin.
|
||||
type TemplateScheduleStore interface {
|
||||
GetTemplateScheduleOptions(ctx context.Context, db database.Store, templateID uuid.UUID) (TemplateScheduleOptions, error)
|
||||
SetTemplateScheduleOptions(ctx context.Context, db database.Store, template database.Template, opts TemplateScheduleOptions) (database.Template, error)
|
||||
Get(ctx context.Context, db database.Store, templateID uuid.UUID) (TemplateScheduleOptions, error)
|
||||
Set(ctx context.Context, db database.Store, template database.Template, opts TemplateScheduleOptions) (database.Template, error)
|
||||
}
|
||||
|
||||
type agplTemplateScheduleStore struct{}
|
||||
@@ -43,7 +121,7 @@ func NewAGPLTemplateScheduleStore() TemplateScheduleStore {
|
||||
return &agplTemplateScheduleStore{}
|
||||
}
|
||||
|
||||
func (*agplTemplateScheduleStore) GetTemplateScheduleOptions(ctx context.Context, db database.Store, templateID uuid.UUID) (TemplateScheduleOptions, error) {
|
||||
func (*agplTemplateScheduleStore) Get(ctx context.Context, db database.Store, templateID uuid.UUID) (TemplateScheduleOptions, error) {
|
||||
tpl, err := db.GetTemplateByID(ctx, templateID)
|
||||
if err != nil {
|
||||
return TemplateScheduleOptions{}, err
|
||||
@@ -55,16 +133,21 @@ func (*agplTemplateScheduleStore) GetTemplateScheduleOptions(ctx context.Context
|
||||
UserAutostartEnabled: true,
|
||||
UserAutostopEnabled: true,
|
||||
DefaultTTL: time.Duration(tpl.DefaultTTL),
|
||||
// Disregard the values in the database, since MaxTTL, FailureTTL, InactivityTTL, and LockedTTL are enterprise
|
||||
// features.
|
||||
MaxTTL: 0,
|
||||
// Disregard the values in the database, since RestartRequirement,
|
||||
// FailureTTL, InactivityTTL, and LockedTTL are enterprise features.
|
||||
UseRestartRequirement: false,
|
||||
MaxTTL: 0,
|
||||
RestartRequirement: TemplateRestartRequirement{
|
||||
DaysOfWeek: 0,
|
||||
Weeks: 0,
|
||||
},
|
||||
FailureTTL: 0,
|
||||
InactivityTTL: 0,
|
||||
LockedTTL: 0,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (*agplTemplateScheduleStore) SetTemplateScheduleOptions(ctx context.Context, db database.Store, tpl database.Template, opts TemplateScheduleOptions) (database.Template, error) {
|
||||
func (*agplTemplateScheduleStore) Set(ctx context.Context, db database.Store, tpl database.Template, opts TemplateScheduleOptions) (database.Template, error) {
|
||||
if int64(opts.DefaultTTL) == tpl.DefaultTTL {
|
||||
// Avoid updating the UpdatedAt timestamp if nothing will be changed.
|
||||
return tpl, nil
|
||||
@@ -76,14 +159,16 @@ func (*agplTemplateScheduleStore) SetTemplateScheduleOptions(ctx context.Context
|
||||
ID: tpl.ID,
|
||||
UpdatedAt: database.Now(),
|
||||
DefaultTTL: int64(opts.DefaultTTL),
|
||||
// Don't allow changing it, but keep the value in the DB (to avoid
|
||||
// clearing settings if the license has an issue).
|
||||
AllowUserAutostart: tpl.AllowUserAutostart,
|
||||
AllowUserAutostop: tpl.AllowUserAutostop,
|
||||
MaxTTL: tpl.MaxTTL,
|
||||
FailureTTL: tpl.FailureTTL,
|
||||
InactivityTTL: tpl.InactivityTTL,
|
||||
LockedTTL: tpl.LockedTTL,
|
||||
// Don't allow changing these settings, but keep the value in the DB (to
|
||||
// avoid clearing settings if the license has an issue).
|
||||
MaxTTL: tpl.MaxTTL,
|
||||
RestartRequirementDaysOfWeek: tpl.RestartRequirementDaysOfWeek,
|
||||
RestartRequirementWeeks: tpl.RestartRequirementWeeks,
|
||||
AllowUserAutostart: tpl.AllowUserAutostart,
|
||||
AllowUserAutostop: tpl.AllowUserAutostop,
|
||||
FailureTTL: tpl.FailureTTL,
|
||||
InactivityTTL: tpl.InactivityTTL,
|
||||
LockedTTL: tpl.LockedTTL,
|
||||
})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("update template schedule: %w", err)
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
package schedule
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/coder/coder/coderd/database"
|
||||
)
|
||||
|
||||
type UserQuietHoursScheduleOptions struct {
|
||||
// Schedule is the cron schedule to use for quiet hours windows for all
|
||||
// workspaces owned by the user.
|
||||
//
|
||||
// This value will be set to the parsed custom schedule of the user. If the
|
||||
// user doesn't have a custom schedule set, it will be set to the default
|
||||
// schedule (and UserSet will be false). If quiet hours schedules are not
|
||||
// entitled or disabled instance-wide, this value will be nil to denote that
|
||||
// quiet hours windows should not be used.
|
||||
Schedule *Schedule
|
||||
UserSet bool
|
||||
}
|
||||
|
||||
type UserQuietHoursScheduleStore interface {
|
||||
// Get retrieves the quiet hours schedule for the given user. If the user
|
||||
// has not set a custom schedule, the default schedule will be returned. If
|
||||
// quiet hours schedules are not entitled or disabled instance-wide, this
|
||||
// will return a nil schedule.
|
||||
Get(ctx context.Context, db database.Store, userID uuid.UUID) (UserQuietHoursScheduleOptions, error)
|
||||
// Set sets the quiet hours schedule for the given user. If the given
|
||||
// schedule is an empty string, the user's custom schedule will be cleared
|
||||
// and the default schedule will be used from now on. If quiet hours
|
||||
// schedules are not entitled or disabled instance-wide, this will do
|
||||
// nothing and return a nil schedule.
|
||||
Set(ctx context.Context, db database.Store, userID uuid.UUID, rawSchedule string) (UserQuietHoursScheduleOptions, error)
|
||||
}
|
||||
|
||||
type agplUserQuietHoursScheduleStore struct{}
|
||||
|
||||
var _ UserQuietHoursScheduleStore = &agplUserQuietHoursScheduleStore{}
|
||||
|
||||
func NewAGPLUserQuietHoursScheduleStore() UserQuietHoursScheduleStore {
|
||||
return &agplUserQuietHoursScheduleStore{}
|
||||
}
|
||||
|
||||
func (*agplUserQuietHoursScheduleStore) Get(_ context.Context, _ database.Store, _ uuid.UUID) (UserQuietHoursScheduleOptions, error) {
|
||||
// User quiet hours windows are not supported in AGPL.
|
||||
return UserQuietHoursScheduleOptions{
|
||||
Schedule: nil,
|
||||
UserSet: false,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (*agplUserQuietHoursScheduleStore) Set(_ context.Context, _ database.Store, _ uuid.UUID, _ string) (UserQuietHoursScheduleOptions, error) {
|
||||
// User quiet hours windows are not supported in AGPL.
|
||||
return UserQuietHoursScheduleOptions{
|
||||
Schedule: nil,
|
||||
UserSet: false,
|
||||
}, nil
|
||||
}
|
||||
Reference in New Issue
Block a user