Files
coder/coderd/autobuild/lifecycle_executor_internal_test.go
T

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")
})
}
}