feat: add user quiet hours schedule and restart requirement feature flag (#8115)

This commit is contained in:
Dean Sheather
2023-07-20 23:35:41 +10:00
committed by GitHub
parent 4821e2e6d8
commit dc8b73168e
67 changed files with 4340 additions and 767 deletions
+332
View File
@@ -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)
}
+594
View File
@@ -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
View File
@@ -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
}
+2 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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)
+60
View File
@@ -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
}