mirror of
https://github.com/coder/coder.git
synced 2026-09-22 05:05:20 +08:00
482 lines
15 KiB
Go
482 lines
15 KiB
Go
package autobuild
|
|
|
|
import (
|
|
"database/sql"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"github.com/coder/coder/v2/coderd/database"
|
|
"github.com/coder/coder/v2/coderd/schedule"
|
|
)
|
|
|
|
func Test_getNextTransition_TaskAutoPause(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// Set up a workspace that is eligible for autostop (past deadline).
|
|
now := time.Now()
|
|
pastDeadline := now.Add(-time.Hour)
|
|
|
|
okUser := database.User{Status: database.UserStatusActive}
|
|
okBuild := database.WorkspaceBuild{
|
|
Transition: database.WorkspaceTransitionStart,
|
|
Deadline: pastDeadline,
|
|
}
|
|
okJob := database.ProvisionerJob{
|
|
JobStatus: database.ProvisionerJobStatusSucceeded,
|
|
}
|
|
okTemplateSchedule := schedule.TemplateScheduleOptions{}
|
|
|
|
// Failed build setup for failedstop tests.
|
|
failedBuild := database.WorkspaceBuild{
|
|
Transition: database.WorkspaceTransitionStart,
|
|
}
|
|
failedJob := database.ProvisionerJob{
|
|
JobStatus: database.ProvisionerJobStatusFailed,
|
|
CompletedAt: sql.NullTime{Time: now.Add(-time.Hour), Valid: true},
|
|
}
|
|
failedTemplateSchedule := schedule.TemplateScheduleOptions{
|
|
FailureTTL: time.Minute, // TTL already elapsed since job completed an hour ago.
|
|
}
|
|
|
|
testCases := []struct {
|
|
Name string
|
|
Workspace database.Workspace
|
|
Build database.WorkspaceBuild
|
|
Job database.ProvisionerJob
|
|
TemplateSchedule schedule.TemplateScheduleOptions
|
|
ExpectedReason database.BuildReason
|
|
}{
|
|
{
|
|
Name: "RegularWorkspace_Autostop",
|
|
Workspace: database.Workspace{
|
|
DormantAt: sql.NullTime{Valid: false},
|
|
},
|
|
Build: okBuild,
|
|
Job: okJob,
|
|
TemplateSchedule: okTemplateSchedule,
|
|
ExpectedReason: database.BuildReasonAutostop,
|
|
},
|
|
{
|
|
Name: "TaskWorkspace_Autostop_UsesTaskAutoPause",
|
|
Workspace: database.Workspace{
|
|
DormantAt: sql.NullTime{Valid: false},
|
|
TaskID: uuid.NullUUID{UUID: uuid.New(), Valid: true},
|
|
},
|
|
Build: okBuild,
|
|
Job: okJob,
|
|
TemplateSchedule: okTemplateSchedule,
|
|
ExpectedReason: database.BuildReasonTaskAutoPause,
|
|
},
|
|
{
|
|
Name: "RegularWorkspace_FailedStop",
|
|
Workspace: database.Workspace{
|
|
DormantAt: sql.NullTime{Valid: false},
|
|
},
|
|
Build: failedBuild,
|
|
Job: failedJob,
|
|
TemplateSchedule: failedTemplateSchedule,
|
|
ExpectedReason: database.BuildReasonAutostop,
|
|
},
|
|
{
|
|
Name: "TaskWorkspace_FailedStop_UsesTaskAutoPause",
|
|
Workspace: database.Workspace{
|
|
DormantAt: sql.NullTime{Valid: false},
|
|
TaskID: uuid.NullUUID{UUID: uuid.New(), Valid: true},
|
|
},
|
|
Build: failedBuild,
|
|
Job: failedJob,
|
|
TemplateSchedule: failedTemplateSchedule,
|
|
ExpectedReason: database.BuildReasonTaskAutoPause,
|
|
},
|
|
}
|
|
|
|
for _, tc := range testCases {
|
|
t.Run(tc.Name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
transition, reason, err := getNextTransition(
|
|
okUser,
|
|
tc.Workspace,
|
|
tc.Build,
|
|
tc.Job,
|
|
tc.TemplateSchedule,
|
|
now,
|
|
)
|
|
require.NoError(t, err)
|
|
require.Equal(t, database.WorkspaceTransitionStop, transition)
|
|
require.Equal(t, tc.ExpectedReason, reason)
|
|
})
|
|
}
|
|
}
|
|
|
|
func Test_getNextTransition_NoAction(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
now := time.Now()
|
|
|
|
// A stopped workspace with no autostart schedule, no dormancy, and no
|
|
// deletion configured has no transition due. The default case must report
|
|
// "nothing to do" via an empty transition AND empty reason, with a nil
|
|
// error (not a sentinel error).
|
|
user := database.User{Status: database.UserStatusActive}
|
|
ws := database.Workspace{
|
|
DormantAt: sql.NullTime{Valid: false},
|
|
}
|
|
build := database.WorkspaceBuild{
|
|
Transition: database.WorkspaceTransitionStop,
|
|
}
|
|
job := database.ProvisionerJob{
|
|
JobStatus: database.ProvisionerJobStatusSucceeded,
|
|
}
|
|
templateSchedule := schedule.TemplateScheduleOptions{}
|
|
|
|
transition, reason, err := getNextTransition(user, ws, build, job, templateSchedule, now)
|
|
require.NoError(t, err)
|
|
require.Equal(t, database.WorkspaceTransition(""), transition)
|
|
require.Equal(t, database.BuildReason(""), reason)
|
|
}
|
|
|
|
func TestShouldRemindAutostop(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
currentTick := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
|
|
const ttl = time.Hour
|
|
|
|
// inWindow places the deadline 30m out, inside the 1h lead window.
|
|
inWindow := func() database.WorkspaceBuild {
|
|
return database.WorkspaceBuild{
|
|
Transition: database.WorkspaceTransitionStart,
|
|
Deadline: currentTick.Add(30 * time.Minute),
|
|
}
|
|
}
|
|
|
|
// idle places last_used_at well outside the 15-minute active threshold so the
|
|
// active-user guard never trips. It is the default for cases that leave
|
|
// LastUsedAt unset; cases that exercise the active-user guard set LastUsedAt
|
|
// explicitly.
|
|
idle := currentTick.Add(-2 * ttl)
|
|
|
|
testCases := []struct {
|
|
Name string
|
|
Build database.WorkspaceBuild
|
|
LastUsedAt time.Time
|
|
TemplateSchedule schedule.TemplateScheduleOptions
|
|
Expected bool
|
|
}{
|
|
{
|
|
Name: "InWindow",
|
|
Build: inWindow(),
|
|
TemplateSchedule: schedule.TemplateScheduleOptions{TimeTilAutostopNotify: ttl},
|
|
Expected: true,
|
|
},
|
|
{
|
|
Name: "TemplateDisabled",
|
|
Build: inWindow(),
|
|
TemplateSchedule: schedule.TemplateScheduleOptions{TimeTilAutostopNotify: 0},
|
|
Expected: false,
|
|
},
|
|
{
|
|
Name: "TransitionStop",
|
|
Build: func() database.WorkspaceBuild {
|
|
b := inWindow()
|
|
b.Transition = database.WorkspaceTransitionStop
|
|
return b
|
|
}(),
|
|
TemplateSchedule: schedule.TemplateScheduleOptions{TimeTilAutostopNotify: ttl},
|
|
Expected: false,
|
|
},
|
|
{
|
|
Name: "ZeroDeadline",
|
|
Build: func() database.WorkspaceBuild {
|
|
b := inWindow()
|
|
b.Deadline = time.Time{}
|
|
return b
|
|
}(),
|
|
TemplateSchedule: schedule.TemplateScheduleOptions{TimeTilAutostopNotify: ttl},
|
|
Expected: false,
|
|
},
|
|
{
|
|
Name: "DeadlineInPast",
|
|
Build: func() database.WorkspaceBuild {
|
|
b := inWindow()
|
|
b.Deadline = currentTick.Add(-time.Minute)
|
|
return b
|
|
}(),
|
|
TemplateSchedule: schedule.TemplateScheduleOptions{TimeTilAutostopNotify: ttl},
|
|
Expected: false,
|
|
},
|
|
{
|
|
Name: "BeforeWindow",
|
|
Build: func() database.WorkspaceBuild {
|
|
b := inWindow()
|
|
// Deadline two hours out, ttl is only one hour.
|
|
b.Deadline = currentTick.Add(2 * time.Hour)
|
|
return b
|
|
}(),
|
|
TemplateSchedule: schedule.TemplateScheduleOptions{TimeTilAutostopNotify: ttl},
|
|
Expected: false,
|
|
},
|
|
{
|
|
Name: "AlreadyNotified",
|
|
Build: func() database.WorkspaceBuild {
|
|
b := inWindow()
|
|
b.NotifiedAutostopDeadline = b.Deadline
|
|
return b
|
|
}(),
|
|
TemplateSchedule: schedule.TemplateScheduleOptions{TimeTilAutostopNotify: ttl},
|
|
Expected: false,
|
|
},
|
|
{
|
|
// Deadline == currentTick: the stop is already due, so
|
|
// !build.Deadline.After(currentTick) rejects it (not a reminder).
|
|
Name: "ExactDeadline",
|
|
Build: func() database.WorkspaceBuild {
|
|
b := inWindow()
|
|
b.Deadline = currentTick
|
|
return b
|
|
}(),
|
|
TemplateSchedule: schedule.TemplateScheduleOptions{TimeTilAutostopNotify: ttl},
|
|
Expected: false,
|
|
},
|
|
{
|
|
// Deadline exactly at the window opening edge (now + ttl) is
|
|
// eligible: the lead-window check uses After, so the edge passes.
|
|
Name: "WindowEdge",
|
|
Build: func() database.WorkspaceBuild {
|
|
b := inWindow()
|
|
b.Deadline = currentTick.Add(ttl)
|
|
return b
|
|
}(),
|
|
TemplateSchedule: schedule.TemplateScheduleOptions{TimeTilAutostopNotify: ttl},
|
|
Expected: true,
|
|
},
|
|
{
|
|
// ActiveUser: the workspace was used within the 15-minute active
|
|
// threshold and activity bumps are enabled with no max_deadline
|
|
// ceiling, so the deadline can keep getting bumped out of the window
|
|
// and the reminder is suppressed.
|
|
Name: "ActiveUser",
|
|
Build: inWindow(),
|
|
LastUsedAt: currentTick.Add(-1 * time.Minute),
|
|
TemplateSchedule: schedule.TemplateScheduleOptions{TimeTilAutostopNotify: ttl, ActivityBump: time.Hour},
|
|
Expected: false,
|
|
},
|
|
{
|
|
// ActiveButMaxDeadlineWithinWindow: the user is active and bumps are
|
|
// enabled, but the hard max_deadline ceiling sits inside the lead
|
|
// window, so a bump cannot push the stop out. The workspace will stop
|
|
// regardless of activity, so we still remind.
|
|
Name: "ActiveButMaxDeadlineWithinWindow",
|
|
Build: func() database.WorkspaceBuild {
|
|
b := inWindow()
|
|
b.MaxDeadline = currentTick.Add(ttl / 2)
|
|
return b
|
|
}(),
|
|
LastUsedAt: currentTick.Add(-1 * time.Minute),
|
|
TemplateSchedule: schedule.TemplateScheduleOptions{TimeTilAutostopNotify: ttl, ActivityBump: time.Hour},
|
|
Expected: true,
|
|
},
|
|
{
|
|
// ActiveButBumpDisabled: the user is active, but activity bumps are
|
|
// disabled (activity_bump == 0), so the deadline cannot move. The
|
|
// workspace will stop, so we still remind.
|
|
Name: "ActiveButBumpDisabled",
|
|
Build: inWindow(),
|
|
LastUsedAt: currentTick.Add(-1 * time.Minute),
|
|
TemplateSchedule: schedule.TemplateScheduleOptions{TimeTilAutostopNotify: ttl, ActivityBump: 0},
|
|
Expected: true,
|
|
},
|
|
{
|
|
// IdleUser: the workspace was last used 20 minutes ago, outside the
|
|
// 15-minute active threshold, so it is not active and the reminder
|
|
// fires. Activity bumps are enabled here, so the true result is
|
|
// genuinely due to idleness and not to disabled bumping.
|
|
Name: "IdleUser",
|
|
Build: inWindow(),
|
|
LastUsedAt: currentTick.Add(-20 * time.Minute),
|
|
TemplateSchedule: schedule.TemplateScheduleOptions{TimeTilAutostopNotify: ttl, ActivityBump: time.Hour},
|
|
Expected: true,
|
|
},
|
|
{
|
|
// Exactly at the threshold: the Go guard (< threshold) treats
|
|
// the user as not active and reminds; the SQL complement
|
|
// (>= threshold) keeps the row. Both agree; pins the boundary
|
|
// against a "<"/">=" off-by-one regression.
|
|
Name: "ActiveThresholdBoundary",
|
|
Build: inWindow(),
|
|
LastUsedAt: currentTick.Add(-autostopReminderActiveThreshold),
|
|
TemplateSchedule: schedule.TemplateScheduleOptions{TimeTilAutostopNotify: ttl, ActivityBump: time.Hour},
|
|
Expected: true,
|
|
},
|
|
}
|
|
|
|
for _, tc := range testCases {
|
|
t.Run(tc.Name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// Cases that do not exercise the active-user guard leave LastUsedAt
|
|
// unset; default those to an idle time outside the lead window.
|
|
lastUsedAt := tc.LastUsedAt
|
|
if lastUsedAt.IsZero() {
|
|
lastUsedAt = idle
|
|
}
|
|
|
|
require.Equal(t, tc.Expected, shouldRemindAutostop(tc.Build, lastUsedAt, tc.TemplateSchedule, currentTick))
|
|
})
|
|
}
|
|
}
|
|
|
|
func Test_isEligibleForAutostart(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// okXXX should be set to values that make 'isEligibleForAutostart' return true.
|
|
|
|
// Intentionally chosen to be a non UTC time that changes the day of the week
|
|
// when converted to UTC.
|
|
localLocation, err := time.LoadLocation("America/Chicago")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// 5s after the autostart in UTC.
|
|
okTick := time.Date(2021, 1, 1, 20, 0, 5, 0, localLocation).UTC()
|
|
okUser := database.User{Status: database.UserStatusActive}
|
|
okWorkspace := database.Workspace{
|
|
DormantAt: sql.NullTime{Valid: false},
|
|
AutostartSchedule: sql.NullString{
|
|
Valid: true,
|
|
// Every day at 8pm America/Chicago, which is 2am UTC the next day.
|
|
String: "CRON_TZ=America/Chicago 0 20 * * *",
|
|
},
|
|
}
|
|
okBuild := database.WorkspaceBuild{
|
|
Transition: database.WorkspaceTransitionStop,
|
|
// Put 24hr before the tick so it's eligible for autostart.
|
|
CreatedAt: okTick.Add(time.Hour * -24),
|
|
}
|
|
okJob := database.ProvisionerJob{
|
|
JobStatus: database.ProvisionerJobStatusSucceeded,
|
|
}
|
|
okTemplateSchedule := schedule.TemplateScheduleOptions{
|
|
UserAutostartEnabled: true,
|
|
AutostartRequirement: schedule.TemplateAutostartRequirement{
|
|
DaysOfWeek: 0b01111111,
|
|
},
|
|
}
|
|
var okWeekdayBit uint8
|
|
for i, weekday := range schedule.DaysOfWeek {
|
|
// Find the local weekday
|
|
if okTick.In(localLocation).Weekday() == weekday {
|
|
// #nosec G115 - Safe conversion as i is the index of a 7-day week and will be in the range 0-6
|
|
okWeekdayBit = 1 << uint(i)
|
|
}
|
|
}
|
|
|
|
testCases := []struct {
|
|
Name string
|
|
User database.User
|
|
Workspace database.Workspace
|
|
Build database.WorkspaceBuild
|
|
Job database.ProvisionerJob
|
|
TemplateSchedule schedule.TemplateScheduleOptions
|
|
Tick time.Time
|
|
|
|
ExpectedResponse bool
|
|
}{
|
|
{
|
|
Name: "Ok",
|
|
User: okUser,
|
|
Workspace: okWorkspace,
|
|
Build: okBuild,
|
|
Job: okJob,
|
|
TemplateSchedule: okTemplateSchedule,
|
|
Tick: okTick,
|
|
ExpectedResponse: true,
|
|
},
|
|
{
|
|
Name: "SuspendedUser",
|
|
User: database.User{Status: database.UserStatusSuspended},
|
|
Workspace: okWorkspace,
|
|
Build: okBuild,
|
|
Job: okJob,
|
|
TemplateSchedule: okTemplateSchedule,
|
|
Tick: okTick,
|
|
ExpectedResponse: false,
|
|
},
|
|
{
|
|
Name: "AutostartOnlyDayEnabled",
|
|
User: okUser,
|
|
Workspace: okWorkspace,
|
|
Build: okBuild,
|
|
Job: okJob,
|
|
TemplateSchedule: schedule.TemplateScheduleOptions{
|
|
UserAutostartEnabled: true,
|
|
AutostartRequirement: schedule.TemplateAutostartRequirement{
|
|
// Specific day of week is allowed
|
|
DaysOfWeek: okWeekdayBit,
|
|
},
|
|
},
|
|
Tick: okTick,
|
|
ExpectedResponse: true,
|
|
},
|
|
{
|
|
Name: "AutostartOnlyDayDisabled",
|
|
User: okUser,
|
|
Workspace: okWorkspace,
|
|
Build: okBuild,
|
|
Job: okJob,
|
|
TemplateSchedule: schedule.TemplateScheduleOptions{
|
|
UserAutostartEnabled: true,
|
|
AutostartRequirement: schedule.TemplateAutostartRequirement{
|
|
// Specific day of week is disallowed
|
|
DaysOfWeek: 0b01111111 & (^okWeekdayBit),
|
|
},
|
|
},
|
|
Tick: okTick,
|
|
ExpectedResponse: false,
|
|
},
|
|
{
|
|
Name: "AutostartAllDaysDisabled",
|
|
User: okUser,
|
|
Workspace: okWorkspace,
|
|
Build: okBuild,
|
|
Job: okJob,
|
|
TemplateSchedule: schedule.TemplateScheduleOptions{
|
|
UserAutostartEnabled: true,
|
|
AutostartRequirement: schedule.TemplateAutostartRequirement{
|
|
// All days disabled
|
|
DaysOfWeek: 0,
|
|
},
|
|
},
|
|
Tick: okTick,
|
|
ExpectedResponse: false,
|
|
},
|
|
{
|
|
Name: "BuildTransitionNotStop",
|
|
User: okUser,
|
|
Workspace: okWorkspace,
|
|
Build: func(b database.WorkspaceBuild) database.WorkspaceBuild {
|
|
cpy := b
|
|
cpy.Transition = database.WorkspaceTransitionStart
|
|
return cpy
|
|
}(okBuild),
|
|
Job: okJob,
|
|
TemplateSchedule: okTemplateSchedule,
|
|
Tick: okTick,
|
|
ExpectedResponse: false,
|
|
},
|
|
}
|
|
|
|
for _, c := range testCases {
|
|
t.Run(c.Name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
autostart := isEligibleForAutostart(c.User, c.Workspace, c.Build, c.Job, c.TemplateSchedule, c.Tick)
|
|
require.Equal(t, c.ExpectedResponse, autostart, "autostart not expected")
|
|
})
|
|
}
|
|
}
|