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/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/dbgen" "github.com/coder/coder/v2/coderd/database/dbtestutil" "github.com/coder/coder/v2/coderd/database/dbtime" "github.com/coder/coder/v2/coderd/schedule" "github.com/coder/coder/v2/coderd/schedule/cron" "github.com/coder/coder/v2/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 autostop 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 templateAutostopRequirement schedule.TemplateAutostopRequirement 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, templateAutostopRequirement: schedule.TemplateAutostopRequirement{}, workspaceTTL: 0, expectedDeadline: time.Time{}, expectedMaxDeadline: time.Time{}, }, { name: "Delete", now: now, templateAllowAutostop: true, templateDefaultTTL: 0, templateAutostopRequirement: schedule.TemplateAutostopRequirement{}, workspaceTTL: 0, expectedDeadline: time.Time{}, expectedMaxDeadline: time.Time{}, }, { name: "WorkspaceTTL", now: now, templateAllowAutostop: true, templateDefaultTTL: 0, templateAutostopRequirement: schedule.TemplateAutostopRequirement{}, workspaceTTL: time.Hour, expectedDeadline: now.Add(time.Hour), expectedMaxDeadline: time.Time{}, }, { name: "TemplateDefaultTTLIgnored", now: now, templateAllowAutostop: true, templateDefaultTTL: time.Hour, templateAutostopRequirement: schedule.TemplateAutostopRequirement{}, workspaceTTL: 0, expectedDeadline: time.Time{}, expectedMaxDeadline: time.Time{}, }, { name: "WorkspaceTTLOverridesTemplateDefaultTTL", now: now, templateAllowAutostop: true, templateDefaultTTL: 2 * time.Hour, templateAutostopRequirement: schedule.TemplateAutostopRequirement{}, workspaceTTL: time.Hour, expectedDeadline: now.Add(time.Hour), expectedMaxDeadline: time.Time{}, }, { name: "TemplateBlockWorkspaceTTL", now: now, templateAllowAutostop: false, templateDefaultTTL: 3 * time.Hour, templateAutostopRequirement: schedule.TemplateAutostopRequirement{}, workspaceTTL: 4 * time.Hour, expectedDeadline: now.Add(3 * time.Hour), expectedMaxDeadline: time.Time{}, }, { name: "TemplateAutostopRequirement", now: wednesdayMidnightUTC, templateAllowAutostop: true, templateDefaultTTL: 0, userQuietHoursSchedule: sydneyQuietHours, templateAutostopRequirement: schedule.TemplateAutostopRequirement{ DaysOfWeek: 0b00100000, // Saturday Weeks: 0, // weekly }, workspaceTTL: 0, // expectedDeadline is copied from expectedMaxDeadline. expectedMaxDeadline: saturdayMidnightSydney.In(time.UTC), }, { name: "TemplateAutostopRequirement1HourSkip", now: saturdayMidnightSydney.Add(-59 * time.Minute), templateAllowAutostop: true, templateDefaultTTL: 0, userQuietHoursSchedule: sydneyQuietHours, templateAutostopRequirement: schedule.TemplateAutostopRequirement{ 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 autostop requirement should be skipped if the // workspace is started within 1 hour of it. name: "TemplateAutostopRequirementDaily", now: fridayEveningSydney, templateAllowAutostop: true, templateDefaultTTL: 0, userQuietHoursSchedule: sydneyQuietHours, templateAutostopRequirement: schedule.TemplateAutostopRequirement{ DaysOfWeek: 0b01111111, // daily Weeks: 0, // all weeks }, workspaceTTL: 0, // expectedDeadline is copied from expectedMaxDeadline. expectedMaxDeadline: saturdayMidnightSydney.In(time.UTC), }, { name: "TemplateAutostopRequirementFortnightly/Skip", now: wednesdayMidnightUTC, templateAllowAutostop: true, templateDefaultTTL: 0, userQuietHoursSchedule: sydneyQuietHours, templateAutostopRequirement: schedule.TemplateAutostopRequirement{ 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: "TemplateAutostopRequirementFortnightly/NoSkip", now: wednesdayMidnightUTC.AddDate(0, 0, 7), templateAllowAutostop: true, templateDefaultTTL: 0, userQuietHoursSchedule: sydneyQuietHours, templateAutostopRequirement: schedule.TemplateAutostopRequirement{ 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: "TemplateAutostopRequirementTriweekly/Skip", now: wednesdayMidnightUTC, templateAllowAutostop: true, templateDefaultTTL: 0, userQuietHoursSchedule: sydneyQuietHours, templateAutostopRequirement: schedule.TemplateAutostopRequirement{ DaysOfWeek: 0b00100000, // Saturday Weeks: 3, // every 3 weeks }, workspaceTTL: 0, // expectedDeadline is copied from expectedMaxDeadline. // The next triweekly autostop requirement happens next week // according to the epoch. expectedMaxDeadline: saturdayMidnightSydney.AddDate(0, 0, 7).In(time.UTC), }, { name: "TemplateAutostopRequirementTriweekly/NoSkip", now: wednesdayMidnightUTC.AddDate(0, 0, 7), templateAllowAutostop: true, templateDefaultTTL: 0, userQuietHoursSchedule: sydneyQuietHours, templateAutostopRequirement: schedule.TemplateAutostopRequirement{ 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: "TemplateAutostopRequirementOverridesWorkspaceTTL", // 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, templateAutostopRequirement: schedule.TemplateAutostopRequirement{ DaysOfWeek: 0b00100000, // Saturday Weeks: 0, // weekly }, workspaceTTL: 3 * time.Hour, // expectedDeadline is copied from expectedMaxDeadline. expectedMaxDeadline: saturdayMidnightSydney.In(time.UTC), }, { name: "TemplateAutostopRequirementOverridesTemplateDefaultTTL", now: fridayEveningSydney.In(time.UTC), templateAllowAutostop: true, templateDefaultTTL: 3 * time.Hour, userQuietHoursSchedule: sydneyQuietHours, templateAutostopRequirement: schedule.TemplateAutostopRequirement{ 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, templateAutostopRequirement: schedule.TemplateAutostopRequirement{ 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, templateAutostopRequirement: schedule.TemplateAutostopRequirement{ 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, templateAutostopRequirement: schedule.TemplateAutostopRequirement{ 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, templateAutostopRequirement: schedule.TemplateAutostopRequirement{ 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, templateAutostopRequirement: schedule.TemplateAutostopRequirement{ 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, templateAutostopRequirement: schedule.TemplateAutostopRequirement{ DaysOfWeek: 0b01000000, // Sunday Weeks: 1, // weekly }, workspaceTTL: 0, // expectedDeadline is copied from expectedMaxDeadline. expectedMaxDeadline: dstOutQuietHoursExpectedTime, }, } 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, AutostopRequirement: c.templateAutostopRequirement, }, 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 := cron.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: dbtime.Now(), AllowUserAutostart: c.templateAllowAutostop, AutostopRequirementDaysOfWeek: int16(c.templateAutostopRequirement.DaysOfWeek), AutostopRequirementWeeks: c.templateAutostopRequirement.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) } }) } }