mirror of
https://github.com/coder/coder.git
synced 2026-06-03 13:08:25 +00:00
1394 lines
49 KiB
Go
1394 lines
49 KiB
Go
package schedule_test
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"fmt"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"cdr.dev/slog/v3"
|
|
"cdr.dev/slog/v3/sloggers/slogtest"
|
|
"github.com/coder/coder/v2/coderd/database"
|
|
"github.com/coder/coder/v2/coderd/database/dbauthz"
|
|
"github.com/coder/coder/v2/coderd/database/dbfake"
|
|
"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/notifications"
|
|
"github.com/coder/coder/v2/coderd/notifications/notificationstest"
|
|
agplschedule "github.com/coder/coder/v2/coderd/schedule"
|
|
"github.com/coder/coder/v2/coderd/schedule/cron"
|
|
"github.com/coder/coder/v2/codersdk"
|
|
"github.com/coder/coder/v2/cryptorand"
|
|
"github.com/coder/coder/v2/enterprise/coderd/schedule"
|
|
"github.com/coder/coder/v2/provisionersdk/proto"
|
|
"github.com/coder/coder/v2/testutil"
|
|
"github.com/coder/quartz"
|
|
)
|
|
|
|
func TestTemplateUpdateBuildDeadlines(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
db, _ := dbtestutil.NewDB(t)
|
|
|
|
var (
|
|
quietUser = dbgen.User(t, db, database.User{
|
|
Username: "quiet",
|
|
})
|
|
noQuietUser = dbgen.User(t, db, database.User{
|
|
Username: "no-quiet",
|
|
})
|
|
file = dbgen.File(t, db, database.File{
|
|
CreatedBy: quietUser.ID,
|
|
})
|
|
templateJob = dbgen.ProvisionerJob(t, db, nil, database.ProvisionerJob{
|
|
FileID: file.ID,
|
|
InitiatorID: quietUser.ID,
|
|
Tags: database.StringMap{
|
|
"foo": "bar",
|
|
},
|
|
})
|
|
templateVersion = dbgen.TemplateVersion(t, db, database.TemplateVersion{
|
|
OrganizationID: templateJob.OrganizationID,
|
|
CreatedBy: quietUser.ID,
|
|
JobID: templateJob.ID,
|
|
})
|
|
organizationID = templateJob.OrganizationID
|
|
)
|
|
|
|
const userQuietHoursSchedule = "CRON_TZ=UTC 0 0 * * *" // midnight UTC
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
quietUser, err := db.UpdateUserQuietHoursSchedule(ctx, database.UpdateUserQuietHoursScheduleParams{
|
|
ID: quietUser.ID,
|
|
QuietHoursSchedule: userQuietHoursSchedule,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
realNow := time.Now().UTC()
|
|
nowY, nowM, nowD := realNow.Date()
|
|
buildTime := time.Date(nowY, nowM, nowD, 12, 0, 0, 0, time.UTC) // noon today UTC
|
|
nextQuietHours := time.Date(nowY, nowM, nowD+1, 0, 0, 0, 0, time.UTC) // midnight tomorrow UTC
|
|
|
|
defaultTTL := 8 * time.Hour
|
|
|
|
cases := []struct {
|
|
name string
|
|
now time.Time
|
|
// Before:
|
|
deadline time.Time
|
|
maxDeadline time.Time
|
|
// After:
|
|
newDeadline time.Time
|
|
newMaxDeadline time.Time
|
|
// Config:
|
|
noQuietHours bool
|
|
// Note that ttl will not influence the new build at all unless it's 0
|
|
// AND the build does not have a max deadline post recalculation.
|
|
ttl time.Duration
|
|
autostopReq *agplschedule.TemplateAutostopRequirement
|
|
}{
|
|
{
|
|
name: "SkippedWorkspaceMaxDeadlineTooSoon",
|
|
now: buildTime,
|
|
deadline: buildTime,
|
|
maxDeadline: buildTime.Add(1 * time.Hour),
|
|
// Unchanged since the max deadline is too soon.
|
|
newDeadline: buildTime,
|
|
newMaxDeadline: buildTime.Add(1 * time.Hour),
|
|
ttl: defaultTTL, // no effect
|
|
},
|
|
{
|
|
name: "NewWorkspaceMaxDeadlineBeforeNow",
|
|
// After the new max deadline...
|
|
now: nextQuietHours.Add(6 * time.Hour),
|
|
deadline: buildTime,
|
|
// Far into the future...
|
|
maxDeadline: nextQuietHours.Add(24 * time.Hour),
|
|
newDeadline: buildTime,
|
|
// We will use now() + 2 hours if the newly calculated max deadline
|
|
// from the workspace build time is before now.
|
|
newMaxDeadline: nextQuietHours.Add(8 * time.Hour),
|
|
ttl: defaultTTL, // no effect
|
|
},
|
|
{
|
|
name: "NewWorkspaceMaxDeadlineSoon",
|
|
// Right before the new max deadline...
|
|
now: nextQuietHours.Add(-1 * time.Hour),
|
|
deadline: buildTime,
|
|
// Far into the future...
|
|
maxDeadline: nextQuietHours.Add(24 * time.Hour),
|
|
newDeadline: buildTime,
|
|
// We will use now() + 2 hours if the newly calculated max deadline
|
|
// from the workspace build time is within the next 2 hours.
|
|
newMaxDeadline: nextQuietHours.Add(1 * time.Hour),
|
|
ttl: defaultTTL, // no effect
|
|
},
|
|
{
|
|
name: "NewWorkspaceMaxDeadlineFuture",
|
|
// Well before the new max deadline...
|
|
now: nextQuietHours.Add(-6 * time.Hour),
|
|
deadline: buildTime,
|
|
// Far into the future...
|
|
maxDeadline: nextQuietHours.Add(24 * time.Hour),
|
|
newDeadline: buildTime,
|
|
newMaxDeadline: nextQuietHours,
|
|
ttl: defaultTTL, // no effect
|
|
},
|
|
{
|
|
name: "DeadlineAfterNewWorkspaceMaxDeadline",
|
|
// Well before the new max deadline...
|
|
now: nextQuietHours.Add(-6 * time.Hour),
|
|
// Far into the future...
|
|
deadline: nextQuietHours.Add(24 * time.Hour),
|
|
maxDeadline: nextQuietHours.Add(24 * time.Hour),
|
|
// The deadline should match since it is after the new max deadline.
|
|
newDeadline: nextQuietHours,
|
|
newMaxDeadline: nextQuietHours,
|
|
ttl: defaultTTL, // no effect
|
|
},
|
|
{
|
|
// There was a bug if a user has no quiet hours set, and autostop
|
|
// req is not turned on, then the max deadline is set to `time.Time{}`.
|
|
// This zero value was "in the past", so the workspace deadline would
|
|
// be set to "now" + 2 hours.
|
|
// This is a mistake because the max deadline being zero means
|
|
// there is no max deadline.
|
|
name: "MaxDeadlineShouldBeUnset",
|
|
now: buildTime,
|
|
deadline: buildTime.Add(time.Hour * 8),
|
|
maxDeadline: time.Time{}, // No max set
|
|
// Should be unchanged
|
|
newDeadline: buildTime.Add(time.Hour * 8),
|
|
newMaxDeadline: time.Time{},
|
|
noQuietHours: true,
|
|
autostopReq: &agplschedule.TemplateAutostopRequirement{
|
|
DaysOfWeek: 0,
|
|
Weeks: 0,
|
|
},
|
|
ttl: defaultTTL, // no effect
|
|
},
|
|
{
|
|
// A bug existed where MaxDeadline could be set, but deadline was
|
|
// `time.Time{}`. This is a logical inconsistency because the "max"
|
|
// deadline was ignored.
|
|
name: "NoDeadline",
|
|
now: buildTime,
|
|
deadline: time.Time{},
|
|
maxDeadline: time.Time{}, // No max set
|
|
// Should be unchanged
|
|
newDeadline: time.Time{},
|
|
newMaxDeadline: time.Time{},
|
|
noQuietHours: true,
|
|
autostopReq: &agplschedule.TemplateAutostopRequirement{
|
|
DaysOfWeek: 0,
|
|
Weeks: 0,
|
|
},
|
|
ttl: defaultTTL, // no effect
|
|
},
|
|
{
|
|
// Similar to 'NoDeadline' test. This has a MaxDeadline set, so
|
|
// the deadline of the workspace should now be set.
|
|
name: "WorkspaceDeadlineNowSet",
|
|
now: nextQuietHours.Add(-6 * time.Hour),
|
|
// Start with unset times
|
|
deadline: time.Time{},
|
|
maxDeadline: time.Time{},
|
|
newDeadline: nextQuietHours,
|
|
newMaxDeadline: nextQuietHours,
|
|
ttl: defaultTTL, // no effect
|
|
},
|
|
{
|
|
// If the build doesn't have a max_deadline anymore, and there is no
|
|
// TTL anymore, then both the deadline and max_deadline should be
|
|
// zero.
|
|
name: "NoTTLNoDeadlineNoMaxDeadline",
|
|
now: buildTime,
|
|
deadline: buildTime.Add(time.Hour * 8),
|
|
maxDeadline: buildTime.Add(time.Hour * 8),
|
|
newDeadline: time.Time{},
|
|
newMaxDeadline: time.Time{},
|
|
noQuietHours: true,
|
|
autostopReq: &agplschedule.TemplateAutostopRequirement{
|
|
DaysOfWeek: 0,
|
|
Weeks: 0,
|
|
},
|
|
ttl: 0,
|
|
},
|
|
}
|
|
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := testutil.Context(t, testutil.WaitShort)
|
|
|
|
user := quietUser
|
|
if c.noQuietHours {
|
|
user = noQuietUser
|
|
}
|
|
|
|
t.Log("buildTime", buildTime)
|
|
t.Log("nextQuietHours", nextQuietHours)
|
|
t.Log("now", c.now)
|
|
t.Log("deadline", c.deadline)
|
|
t.Log("maxDeadline", c.maxDeadline)
|
|
t.Log("newDeadline", c.newDeadline)
|
|
t.Log("newMaxDeadline", c.newMaxDeadline)
|
|
t.Log("ttl", c.ttl)
|
|
|
|
template := dbgen.Template(t, db, database.Template{
|
|
OrganizationID: organizationID,
|
|
ActiveVersionID: templateVersion.ID,
|
|
CreatedBy: user.ID,
|
|
})
|
|
buildResp := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{
|
|
OrganizationID: organizationID,
|
|
OwnerID: user.ID,
|
|
TemplateID: template.ID,
|
|
}).Seed(database.WorkspaceBuild{
|
|
TemplateVersionID: templateVersion.ID,
|
|
}).ProvisionerState([]byte(must(cryptorand.String(64)))).Succeeded(dbfake.WithJobCompletedAt(buildTime)).Do()
|
|
|
|
// Assert test invariant: workspace build state must not be empty
|
|
var buildProvisionerState []byte
|
|
buildProvisionerStateRow, err := db.GetWorkspaceBuildProvisionerStateByID(ctx, buildResp.Build.ID)
|
|
require.NoError(t, err)
|
|
buildProvisionerState = buildProvisionerStateRow.ProvisionerState
|
|
require.NotEmpty(t, buildProvisionerState, "provisioner state must not be empty")
|
|
|
|
err = db.UpdateWorkspaceBuildDeadlineByID(ctx, database.UpdateWorkspaceBuildDeadlineByIDParams{
|
|
ID: buildResp.Build.ID,
|
|
UpdatedAt: buildTime,
|
|
Deadline: c.deadline,
|
|
MaxDeadline: c.maxDeadline,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
wsBuild, err := db.GetWorkspaceBuildByID(ctx, buildResp.Build.ID)
|
|
require.NoError(t, err)
|
|
|
|
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug)
|
|
|
|
userQuietHoursStore, err := schedule.NewEnterpriseUserQuietHoursScheduleStore(userQuietHoursSchedule, true)
|
|
require.NoError(t, err)
|
|
userQuietHoursStorePtr := &atomic.Pointer[agplschedule.UserQuietHoursScheduleStore]{}
|
|
userQuietHoursStorePtr.Store(&userQuietHoursStore)
|
|
|
|
clock := quartz.NewMock(t)
|
|
clock.Set(c.now)
|
|
|
|
// Set the template policy.
|
|
templateScheduleStore := schedule.NewEnterpriseTemplateScheduleStore(userQuietHoursStorePtr, notifications.NewNoopEnqueuer(), logger, clock)
|
|
|
|
autostopReq := agplschedule.TemplateAutostopRequirement{
|
|
// Every day
|
|
DaysOfWeek: 0b01111111,
|
|
Weeks: 0,
|
|
}
|
|
if c.autostopReq != nil {
|
|
autostopReq = *c.autostopReq
|
|
}
|
|
_, err = templateScheduleStore.Set(ctx, db, template, agplschedule.TemplateScheduleOptions{
|
|
UserAutostartEnabled: false,
|
|
UserAutostopEnabled: false,
|
|
DefaultTTL: c.ttl,
|
|
AutostopRequirement: autostopReq,
|
|
FailureTTL: 0,
|
|
TimeTilDormant: 0,
|
|
TimeTilDormantAutoDelete: 0,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
// Check that the workspace build has the expected deadlines.
|
|
newBuild, err := db.GetWorkspaceBuildByID(ctx, wsBuild.ID)
|
|
require.NoError(t, err)
|
|
|
|
require.WithinDuration(t, c.newDeadline, newBuild.Deadline, time.Second, "deadline")
|
|
require.WithinDuration(t, c.newMaxDeadline, newBuild.MaxDeadline, time.Second, "max_deadline")
|
|
|
|
// Check that the new build has the same state as before.
|
|
newBuildProvisionerStateRow, err := db.GetWorkspaceBuildProvisionerStateByID(ctx, newBuild.ID)
|
|
require.NoError(t, err)
|
|
require.Equal(t, buildProvisionerState, newBuildProvisionerStateRow.ProvisionerState, "provisioner state mismatch")
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestTemplateUpdateBuildDeadlinesSkip(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
db, _ := dbtestutil.NewDB(t)
|
|
|
|
var (
|
|
user = dbgen.User(t, db, database.User{})
|
|
file = dbgen.File(t, db, database.File{
|
|
CreatedBy: user.ID,
|
|
})
|
|
templateJob = dbgen.ProvisionerJob(t, db, nil, database.ProvisionerJob{
|
|
FileID: file.ID,
|
|
InitiatorID: user.ID,
|
|
Tags: database.StringMap{
|
|
"foo": "bar",
|
|
},
|
|
})
|
|
templateVersion = dbgen.TemplateVersion(t, db, database.TemplateVersion{
|
|
CreatedBy: user.ID,
|
|
JobID: templateJob.ID,
|
|
OrganizationID: templateJob.OrganizationID,
|
|
})
|
|
template = dbgen.Template(t, db, database.Template{
|
|
ActiveVersionID: templateVersion.ID,
|
|
CreatedBy: user.ID,
|
|
OrganizationID: templateJob.OrganizationID,
|
|
})
|
|
otherTemplate = dbgen.Template(t, db, database.Template{
|
|
ActiveVersionID: templateVersion.ID,
|
|
CreatedBy: user.ID,
|
|
OrganizationID: templateJob.OrganizationID,
|
|
})
|
|
)
|
|
|
|
// Create a workspace that will be shared by two builds.
|
|
ws := dbgen.Workspace(t, db, database.WorkspaceTable{
|
|
OwnerID: user.ID,
|
|
TemplateID: template.ID,
|
|
OrganizationID: templateJob.OrganizationID,
|
|
})
|
|
|
|
const userQuietHoursSchedule = "CRON_TZ=UTC 0 0 * * *" // midnight UTC
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
user, err := db.UpdateUserQuietHoursSchedule(ctx, database.UpdateUserQuietHoursScheduleParams{
|
|
ID: user.ID,
|
|
QuietHoursSchedule: userQuietHoursSchedule,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
realNow := time.Now().UTC()
|
|
nowY, nowM, nowD := realNow.Date()
|
|
buildTime := time.Date(nowY, nowM, nowD, 12, 0, 0, 0, time.UTC) // noon today UTC
|
|
now := time.Date(nowY, nowM, nowD, 18, 0, 0, 0, time.UTC) // 6pm today UTC
|
|
nextQuietHours := time.Date(nowY, nowM, nowD+1, 0, 0, 0, 0, time.UTC) // midnight tomorrow UTC
|
|
|
|
// A date very far in the future which would definitely be updated.
|
|
originalMaxDeadline := time.Date(nowY+1, nowM, nowD, 0, 0, 0, 0, time.UTC)
|
|
|
|
_ = otherTemplate
|
|
|
|
builds := []struct {
|
|
name string
|
|
templateID uuid.UUID
|
|
// Nil workspaceID means create a new workspace.
|
|
workspaceID uuid.UUID
|
|
buildNumber int32
|
|
buildStarted bool
|
|
buildCompleted bool
|
|
buildError bool
|
|
|
|
shouldBeUpdated bool
|
|
|
|
// Set below:
|
|
wsBuild database.WorkspaceBuild
|
|
wsBuildProvisionerState []byte
|
|
}{
|
|
{
|
|
name: "DifferentTemplate",
|
|
templateID: otherTemplate.ID,
|
|
workspaceID: uuid.Nil,
|
|
buildNumber: 1,
|
|
buildStarted: true,
|
|
buildCompleted: true,
|
|
buildError: false,
|
|
shouldBeUpdated: false,
|
|
},
|
|
{
|
|
name: "NonStartedBuild",
|
|
templateID: template.ID,
|
|
workspaceID: uuid.Nil,
|
|
buildNumber: 1,
|
|
buildStarted: false,
|
|
buildCompleted: false,
|
|
buildError: false,
|
|
shouldBeUpdated: false,
|
|
},
|
|
{
|
|
name: "InProgressBuild",
|
|
templateID: template.ID,
|
|
workspaceID: uuid.Nil,
|
|
buildNumber: 1,
|
|
buildStarted: true,
|
|
buildCompleted: false,
|
|
buildError: false,
|
|
shouldBeUpdated: false,
|
|
},
|
|
{
|
|
name: "FailedBuild",
|
|
templateID: template.ID,
|
|
workspaceID: uuid.Nil,
|
|
buildNumber: 1,
|
|
buildStarted: true,
|
|
buildCompleted: true,
|
|
buildError: true,
|
|
shouldBeUpdated: false,
|
|
},
|
|
{
|
|
name: "NonLatestBuild",
|
|
templateID: template.ID,
|
|
workspaceID: ws.ID,
|
|
buildNumber: 1,
|
|
buildStarted: true,
|
|
buildCompleted: true,
|
|
buildError: false,
|
|
// This build was successful but is not the latest build for this
|
|
// workspace, see the next build.
|
|
shouldBeUpdated: false,
|
|
},
|
|
{
|
|
name: "LatestBuild",
|
|
templateID: template.ID,
|
|
workspaceID: ws.ID,
|
|
buildNumber: 2,
|
|
buildStarted: true,
|
|
buildCompleted: true,
|
|
buildError: false,
|
|
shouldBeUpdated: true,
|
|
},
|
|
{
|
|
name: "LatestBuildOtherWorkspace",
|
|
templateID: template.ID,
|
|
workspaceID: uuid.Nil,
|
|
buildNumber: 1,
|
|
buildStarted: true,
|
|
buildCompleted: true,
|
|
buildError: false,
|
|
shouldBeUpdated: true,
|
|
},
|
|
}
|
|
|
|
for i, b := range builds {
|
|
wsID := b.workspaceID
|
|
if wsID == uuid.Nil {
|
|
ws := dbgen.Workspace(t, db, database.WorkspaceTable{
|
|
OwnerID: user.ID,
|
|
TemplateID: b.templateID,
|
|
OrganizationID: templateJob.OrganizationID,
|
|
})
|
|
wsID = ws.ID
|
|
}
|
|
job := dbgen.ProvisionerJob(t, db, nil, database.ProvisionerJob{
|
|
FileID: file.ID,
|
|
InitiatorID: user.ID,
|
|
Provisioner: database.ProvisionerTypeEcho,
|
|
Tags: database.StringMap{
|
|
wsID.String(): "yeah",
|
|
},
|
|
OrganizationID: templateJob.OrganizationID,
|
|
})
|
|
wsBuildProvisionerState := []byte(must(cryptorand.String(64)))
|
|
wsBuild := dbgen.WorkspaceBuild(t, db, database.WorkspaceBuild{
|
|
WorkspaceID: wsID,
|
|
BuildNumber: b.buildNumber,
|
|
JobID: job.ID,
|
|
InitiatorID: user.ID,
|
|
TemplateVersionID: templateVersion.ID,
|
|
})
|
|
err = db.UpdateWorkspaceBuildProvisionerStateByID(ctx, database.UpdateWorkspaceBuildProvisionerStateByIDParams{
|
|
ID: wsBuild.ID,
|
|
UpdatedAt: wsBuild.UpdatedAt,
|
|
ProvisionerState: wsBuildProvisionerState,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
// Assert test invariant: workspace build state must not be empty
|
|
require.NotEmpty(t, wsBuildProvisionerState, "provisioner state must not be empty")
|
|
|
|
err = db.UpdateWorkspaceBuildDeadlineByID(ctx, database.UpdateWorkspaceBuildDeadlineByIDParams{
|
|
ID: wsBuild.ID,
|
|
UpdatedAt: buildTime,
|
|
Deadline: originalMaxDeadline,
|
|
MaxDeadline: originalMaxDeadline,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
wsBuild, err = db.GetWorkspaceBuildByID(ctx, wsBuild.ID)
|
|
require.NoError(t, err)
|
|
|
|
// Assert test invariant: workspace build state must not be empty
|
|
require.NotEmpty(t, wsBuildProvisionerState, "provisioner state must not be empty")
|
|
|
|
builds[i].wsBuildProvisionerState = wsBuildProvisionerState
|
|
builds[i].wsBuild = wsBuild
|
|
|
|
if !b.buildStarted {
|
|
continue
|
|
}
|
|
|
|
acquiredJob, err := db.AcquireProvisionerJob(ctx, database.AcquireProvisionerJobParams{
|
|
OrganizationID: job.OrganizationID,
|
|
StartedAt: sql.NullTime{
|
|
Time: buildTime,
|
|
Valid: true,
|
|
},
|
|
WorkerID: uuid.NullUUID{
|
|
UUID: uuid.New(),
|
|
Valid: true,
|
|
},
|
|
Types: []database.ProvisionerType{database.ProvisionerTypeEcho},
|
|
ProvisionerTags: json.RawMessage(fmt.Sprintf(`{%q: "yeah"}`, wsID)),
|
|
})
|
|
require.NoError(t, err)
|
|
require.Equal(t, job.ID, acquiredJob.ID)
|
|
|
|
if !b.buildCompleted {
|
|
continue
|
|
}
|
|
|
|
buildError := ""
|
|
if b.buildError {
|
|
buildError = "error"
|
|
}
|
|
err = db.UpdateProvisionerJobWithCompleteByID(ctx, database.UpdateProvisionerJobWithCompleteByIDParams{
|
|
ID: job.ID,
|
|
CompletedAt: sql.NullTime{
|
|
Time: buildTime,
|
|
Valid: true,
|
|
},
|
|
Error: sql.NullString{
|
|
String: buildError,
|
|
Valid: b.buildError,
|
|
},
|
|
UpdatedAt: buildTime,
|
|
})
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug)
|
|
|
|
userQuietHoursStore, err := schedule.NewEnterpriseUserQuietHoursScheduleStore(userQuietHoursSchedule, true)
|
|
require.NoError(t, err)
|
|
userQuietHoursStorePtr := &atomic.Pointer[agplschedule.UserQuietHoursScheduleStore]{}
|
|
userQuietHoursStorePtr.Store(&userQuietHoursStore)
|
|
|
|
clock := quartz.NewMock(t)
|
|
clock.Set(now)
|
|
|
|
// Set the template policy.
|
|
templateScheduleStore := schedule.NewEnterpriseTemplateScheduleStore(userQuietHoursStorePtr, notifications.NewNoopEnqueuer(), logger, clock)
|
|
_, err = templateScheduleStore.Set(ctx, db, template, agplschedule.TemplateScheduleOptions{
|
|
UserAutostartEnabled: false,
|
|
UserAutostopEnabled: false,
|
|
DefaultTTL: 0,
|
|
AutostopRequirement: agplschedule.TemplateAutostopRequirement{
|
|
// Every day
|
|
DaysOfWeek: 0b01111111,
|
|
Weeks: 0,
|
|
},
|
|
FailureTTL: 0,
|
|
TimeTilDormant: 0,
|
|
TimeTilDormantAutoDelete: 0,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
// Check each build.
|
|
for i, b := range builds {
|
|
msg := fmt.Sprintf("build %d: %s", i, b.name)
|
|
newBuild, err := db.GetWorkspaceBuildByID(ctx, b.wsBuild.ID)
|
|
require.NoError(t, err)
|
|
|
|
if b.shouldBeUpdated {
|
|
assert.WithinDuration(t, nextQuietHours, newBuild.Deadline, time.Second, msg)
|
|
assert.WithinDuration(t, nextQuietHours, newBuild.MaxDeadline, time.Second, msg)
|
|
} else {
|
|
assert.WithinDuration(t, originalMaxDeadline, newBuild.Deadline, time.Second, msg)
|
|
assert.WithinDuration(t, originalMaxDeadline, newBuild.MaxDeadline, time.Second, msg)
|
|
}
|
|
|
|
newBuildProvisionerStateRow, err := db.GetWorkspaceBuildProvisionerStateByID(ctx, newBuild.ID)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, builds[i].wsBuildProvisionerState, newBuildProvisionerStateRow.ProvisionerState, "provisioner state mismatch")
|
|
}
|
|
}
|
|
|
|
func TestNotifications(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
t.Run("Dormancy", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
var (
|
|
db, _ = dbtestutil.NewDB(t)
|
|
ctx = testutil.Context(t, testutil.WaitLong)
|
|
user = dbgen.User(t, db, database.User{})
|
|
file = dbgen.File(t, db, database.File{
|
|
CreatedBy: user.ID,
|
|
})
|
|
templateJob = dbgen.ProvisionerJob(t, db, nil, database.ProvisionerJob{
|
|
FileID: file.ID,
|
|
InitiatorID: user.ID,
|
|
Tags: database.StringMap{
|
|
"foo": "bar",
|
|
},
|
|
})
|
|
timeTilDormant = time.Minute * 2
|
|
templateVersion = dbgen.TemplateVersion(t, db, database.TemplateVersion{
|
|
CreatedBy: user.ID,
|
|
JobID: templateJob.ID,
|
|
OrganizationID: templateJob.OrganizationID,
|
|
})
|
|
template = dbgen.Template(t, db, database.Template{
|
|
ActiveVersionID: templateVersion.ID,
|
|
CreatedBy: user.ID,
|
|
OrganizationID: templateJob.OrganizationID,
|
|
TimeTilDormant: int64(timeTilDormant),
|
|
TimeTilDormantAutoDelete: int64(timeTilDormant),
|
|
})
|
|
)
|
|
|
|
// Add two dormant workspaces and one active workspace.
|
|
dormantWorkspaces := []database.WorkspaceTable{
|
|
dbgen.Workspace(t, db, database.WorkspaceTable{
|
|
OwnerID: user.ID,
|
|
TemplateID: template.ID,
|
|
OrganizationID: templateJob.OrganizationID,
|
|
LastUsedAt: time.Now().Add(-time.Hour),
|
|
}),
|
|
dbgen.Workspace(t, db, database.WorkspaceTable{
|
|
OwnerID: user.ID,
|
|
TemplateID: template.ID,
|
|
OrganizationID: templateJob.OrganizationID,
|
|
LastUsedAt: time.Now().Add(-time.Hour),
|
|
}),
|
|
}
|
|
dbgen.Workspace(t, db, database.WorkspaceTable{
|
|
OwnerID: user.ID,
|
|
TemplateID: template.ID,
|
|
OrganizationID: templateJob.OrganizationID,
|
|
LastUsedAt: time.Now(),
|
|
})
|
|
for _, ws := range dormantWorkspaces {
|
|
db.UpdateWorkspaceDormantDeletingAt(ctx, database.UpdateWorkspaceDormantDeletingAtParams{
|
|
ID: ws.ID,
|
|
DormantAt: sql.NullTime{
|
|
Time: ws.LastUsedAt.Add(timeTilDormant),
|
|
Valid: true,
|
|
},
|
|
})
|
|
}
|
|
|
|
// Setup dependencies
|
|
notifyEnq := notificationstest.FakeEnqueuer{}
|
|
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug)
|
|
const userQuietHoursSchedule = "CRON_TZ=UTC 0 0 * * *" // midnight UTC
|
|
userQuietHoursStore, err := schedule.NewEnterpriseUserQuietHoursScheduleStore(userQuietHoursSchedule, true)
|
|
require.NoError(t, err)
|
|
userQuietHoursStorePtr := &atomic.Pointer[agplschedule.UserQuietHoursScheduleStore]{}
|
|
userQuietHoursStorePtr.Store(&userQuietHoursStore)
|
|
templateScheduleStore := schedule.NewEnterpriseTemplateScheduleStore(userQuietHoursStorePtr, ¬ifyEnq, logger, nil)
|
|
|
|
// Lower the dormancy TTL to ensure the schedule recalculates deadlines and
|
|
// triggers notifications.
|
|
_, err = templateScheduleStore.Set(dbauthz.AsNotifier(ctx), db, template, agplschedule.TemplateScheduleOptions{
|
|
TimeTilDormant: timeTilDormant / 2,
|
|
TimeTilDormantAutoDelete: timeTilDormant / 2,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
// We should expect a notification for each dormant workspace.
|
|
sent := notifyEnq.Sent()
|
|
require.Len(t, sent, len(dormantWorkspaces))
|
|
for i, dormantWs := range dormantWorkspaces {
|
|
require.Equal(t, sent[i].UserID, dormantWs.OwnerID)
|
|
require.Equal(t, sent[i].TemplateID, notifications.TemplateWorkspaceMarkedForDeletion)
|
|
require.Contains(t, sent[i].Targets, template.ID)
|
|
require.Contains(t, sent[i].Targets, dormantWs.ID)
|
|
require.Contains(t, sent[i].Targets, dormantWs.OrganizationID)
|
|
require.Contains(t, sent[i].Targets, dormantWs.OwnerID)
|
|
}
|
|
})
|
|
|
|
// Regression test for https://github.com/coder/coder/issues/20913
|
|
// Deleted workspaces should not receive dormancy notifications.
|
|
t.Run("DeletedWorkspacesNotNotified", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
var (
|
|
db, _ = dbtestutil.NewDB(t)
|
|
ctx = testutil.Context(t, testutil.WaitLong)
|
|
user = dbgen.User(t, db, database.User{})
|
|
file = dbgen.File(t, db, database.File{
|
|
CreatedBy: user.ID,
|
|
})
|
|
templateJob = dbgen.ProvisionerJob(t, db, nil, database.ProvisionerJob{
|
|
FileID: file.ID,
|
|
InitiatorID: user.ID,
|
|
Tags: database.StringMap{
|
|
"foo": "bar",
|
|
},
|
|
})
|
|
timeTilDormant = time.Minute * 2
|
|
templateVersion = dbgen.TemplateVersion(t, db, database.TemplateVersion{
|
|
CreatedBy: user.ID,
|
|
JobID: templateJob.ID,
|
|
OrganizationID: templateJob.OrganizationID,
|
|
})
|
|
template = dbgen.Template(t, db, database.Template{
|
|
ActiveVersionID: templateVersion.ID,
|
|
CreatedBy: user.ID,
|
|
OrganizationID: templateJob.OrganizationID,
|
|
TimeTilDormant: int64(timeTilDormant),
|
|
TimeTilDormantAutoDelete: int64(timeTilDormant),
|
|
})
|
|
)
|
|
|
|
// Create a dormant workspace that is NOT deleted.
|
|
activeDormantWorkspace := dbgen.Workspace(t, db, database.WorkspaceTable{
|
|
OwnerID: user.ID,
|
|
TemplateID: template.ID,
|
|
OrganizationID: templateJob.OrganizationID,
|
|
LastUsedAt: time.Now().Add(-time.Hour),
|
|
})
|
|
_, err := db.UpdateWorkspaceDormantDeletingAt(ctx, database.UpdateWorkspaceDormantDeletingAtParams{
|
|
ID: activeDormantWorkspace.ID,
|
|
DormantAt: sql.NullTime{
|
|
Time: activeDormantWorkspace.LastUsedAt.Add(timeTilDormant),
|
|
Valid: true,
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
// Create a dormant workspace that IS deleted.
|
|
deletedDormantWorkspace := dbgen.Workspace(t, db, database.WorkspaceTable{
|
|
OwnerID: user.ID,
|
|
TemplateID: template.ID,
|
|
OrganizationID: templateJob.OrganizationID,
|
|
LastUsedAt: time.Now().Add(-time.Hour),
|
|
Deleted: true, // Mark as deleted
|
|
})
|
|
_, err = db.UpdateWorkspaceDormantDeletingAt(ctx, database.UpdateWorkspaceDormantDeletingAtParams{
|
|
ID: deletedDormantWorkspace.ID,
|
|
DormantAt: sql.NullTime{
|
|
Time: deletedDormantWorkspace.LastUsedAt.Add(timeTilDormant),
|
|
Valid: true,
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
// Setup dependencies
|
|
notifyEnq := notificationstest.NewFakeEnqueuer()
|
|
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug)
|
|
const userQuietHoursSchedule = "CRON_TZ=UTC 0 0 * * *" // midnight UTC
|
|
userQuietHoursStore, err := schedule.NewEnterpriseUserQuietHoursScheduleStore(userQuietHoursSchedule, true)
|
|
require.NoError(t, err)
|
|
userQuietHoursStorePtr := &atomic.Pointer[agplschedule.UserQuietHoursScheduleStore]{}
|
|
userQuietHoursStorePtr.Store(&userQuietHoursStore)
|
|
templateScheduleStore := schedule.NewEnterpriseTemplateScheduleStore(userQuietHoursStorePtr, notifyEnq, logger, nil)
|
|
|
|
// Lower the dormancy TTL to ensure the schedule recalculates deadlines and
|
|
// triggers notifications.
|
|
_, err = templateScheduleStore.Set(dbauthz.AsNotifier(ctx), db, template, agplschedule.TemplateScheduleOptions{
|
|
TimeTilDormant: timeTilDormant / 2,
|
|
TimeTilDormantAutoDelete: timeTilDormant / 2,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
// We should only receive a notification for the non-deleted dormant workspace.
|
|
sent := notifyEnq.Sent()
|
|
require.Len(t, sent, 1, "expected exactly 1 notification for the non-deleted workspace")
|
|
require.Equal(t, sent[0].UserID, activeDormantWorkspace.OwnerID)
|
|
require.Equal(t, sent[0].TemplateID, notifications.TemplateWorkspaceMarkedForDeletion)
|
|
require.Contains(t, sent[0].Targets, activeDormantWorkspace.ID)
|
|
|
|
// Ensure the deleted workspace was NOT notified
|
|
for _, notification := range sent {
|
|
require.NotContains(t, notification.Targets, deletedDormantWorkspace.ID,
|
|
"deleted workspace should not receive notifications")
|
|
}
|
|
})
|
|
|
|
// Disabling dormancy auto-deletion should not send "marked for deletion" notifications.
|
|
t.Run("DisablingAutoDeleteSendsNoNotifications", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
var (
|
|
db, _ = dbtestutil.NewDB(t)
|
|
user = dbgen.User(t, db, database.User{})
|
|
file = dbgen.File(t, db, database.File{
|
|
CreatedBy: user.ID,
|
|
})
|
|
templateJob = dbgen.ProvisionerJob(t, db, nil, database.ProvisionerJob{
|
|
FileID: file.ID,
|
|
InitiatorID: user.ID,
|
|
Tags: database.StringMap{
|
|
"foo": "bar",
|
|
},
|
|
})
|
|
timeTilDormant = time.Minute * 2
|
|
timeTilDormantAutoDelete = time.Minute * 4
|
|
templateVersion = dbgen.TemplateVersion(t, db, database.TemplateVersion{
|
|
CreatedBy: user.ID,
|
|
JobID: templateJob.ID,
|
|
OrganizationID: templateJob.OrganizationID,
|
|
})
|
|
template = dbgen.Template(t, db, database.Template{
|
|
ActiveVersionID: templateVersion.ID,
|
|
CreatedBy: user.ID,
|
|
OrganizationID: templateJob.OrganizationID,
|
|
})
|
|
)
|
|
|
|
// Given: Dormancy auto deletion is enabled
|
|
ctx := testutil.Context(t, testutil.WaitShort)
|
|
err := db.UpdateTemplateScheduleByID(ctx, database.UpdateTemplateScheduleByIDParams{
|
|
ID: template.ID,
|
|
UpdatedAt: dbtime.Now(),
|
|
TimeTilDormant: int64(timeTilDormant),
|
|
TimeTilDormantAutoDelete: int64(timeTilDormantAutoDelete),
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
// Given: A workspace that is marked as dormant
|
|
workspace := dbgen.Workspace(t, db, database.WorkspaceTable{
|
|
OwnerID: user.ID,
|
|
TemplateID: template.ID,
|
|
OrganizationID: templateJob.OrganizationID,
|
|
LastUsedAt: time.Now().Add(-time.Hour),
|
|
})
|
|
dormantAt := workspace.LastUsedAt.Add(timeTilDormant)
|
|
workspace, err = db.UpdateWorkspaceDormantDeletingAt(ctx, database.UpdateWorkspaceDormantDeletingAtParams{
|
|
ID: workspace.ID,
|
|
DormantAt: sql.NullTime{
|
|
Time: dormantAt,
|
|
Valid: true,
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
require.True(t, workspace.DeletingAt.Valid, "deleting_at should be set when marking workspace dormant")
|
|
|
|
// Setup dependencies
|
|
notifyEnq := notificationstest.NewFakeEnqueuer()
|
|
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug)
|
|
const userQuietHoursSchedule = "CRON_TZ=UTC 0 0 * * *" // midnight UTC
|
|
userQuietHoursStore, err := schedule.NewEnterpriseUserQuietHoursScheduleStore(userQuietHoursSchedule, true)
|
|
require.NoError(t, err)
|
|
userQuietHoursStorePtr := &atomic.Pointer[agplschedule.UserQuietHoursScheduleStore]{}
|
|
userQuietHoursStorePtr.Store(&userQuietHoursStore)
|
|
templateScheduleStore := schedule.NewEnterpriseTemplateScheduleStore(userQuietHoursStorePtr, notifyEnq, logger, nil)
|
|
|
|
// When: We disable dormancy auto-delete
|
|
_, err = templateScheduleStore.Set(dbauthz.AsNotifier(ctx), db, template, agplschedule.TemplateScheduleOptions{
|
|
TimeTilDormant: timeTilDormant,
|
|
TimeTilDormantAutoDelete: 0,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
// Then: We expect deleting_at to be removed
|
|
updated, err := db.GetWorkspaceByID(ctx, workspace.ID)
|
|
require.NoError(t, err)
|
|
require.False(t, updated.DeletingAt.Valid, "deleting_at should be cleared when auto-deletion is disabled")
|
|
|
|
// Then: We expect no notifications to have been sent
|
|
sent := notifyEnq.Sent()
|
|
require.Len(t, sent, 0, "no notifications should be sent when disabling dormancy auto-deletion")
|
|
})
|
|
}
|
|
|
|
func TestTemplateTTL(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
name string
|
|
allowUserAutostop bool
|
|
fromTTL time.Duration
|
|
toTTL time.Duration
|
|
expected sql.NullInt64
|
|
}{
|
|
{
|
|
name: "AllowUserAutostopFalse/ModifyTTLDurationDown",
|
|
allowUserAutostop: false,
|
|
fromTTL: 24 * time.Hour,
|
|
toTTL: 1 * time.Hour,
|
|
expected: sql.NullInt64{Valid: true, Int64: int64(1 * time.Hour)},
|
|
},
|
|
{
|
|
name: "AllowUserAutostopFalse/ModifyTTLDurationUp",
|
|
allowUserAutostop: false,
|
|
fromTTL: 24 * time.Hour,
|
|
toTTL: 36 * time.Hour,
|
|
expected: sql.NullInt64{Valid: true, Int64: int64(36 * time.Hour)},
|
|
},
|
|
{
|
|
name: "AllowUserAutostopFalse/ModifyTTLDurationSame",
|
|
allowUserAutostop: false,
|
|
fromTTL: 24 * time.Hour,
|
|
toTTL: 24 * time.Hour,
|
|
expected: sql.NullInt64{Valid: true, Int64: int64(24 * time.Hour)},
|
|
},
|
|
{
|
|
name: "AllowUserAutostopFalse/DisableTTL",
|
|
allowUserAutostop: false,
|
|
fromTTL: 24 * time.Hour,
|
|
toTTL: 0,
|
|
expected: sql.NullInt64{},
|
|
},
|
|
{
|
|
name: "AllowUserAutostopTrue/ModifyTTLDurationDown",
|
|
allowUserAutostop: true,
|
|
fromTTL: 24 * time.Hour,
|
|
toTTL: 1 * time.Hour,
|
|
expected: sql.NullInt64{Valid: true, Int64: int64(24 * time.Hour)},
|
|
},
|
|
{
|
|
name: "AllowUserAutostopTrue/ModifyTTLDurationUp",
|
|
allowUserAutostop: true,
|
|
fromTTL: 24 * time.Hour,
|
|
toTTL: 36 * time.Hour,
|
|
expected: sql.NullInt64{Valid: true, Int64: int64(24 * time.Hour)},
|
|
},
|
|
{
|
|
name: "AllowUserAutostopTrue/ModifyTTLDurationSame",
|
|
allowUserAutostop: true,
|
|
fromTTL: 24 * time.Hour,
|
|
toTTL: 24 * time.Hour,
|
|
expected: sql.NullInt64{Valid: true, Int64: int64(24 * time.Hour)},
|
|
},
|
|
{
|
|
name: "AllowUserAutostopTrue/DisableTTL",
|
|
allowUserAutostop: true,
|
|
fromTTL: 24 * time.Hour,
|
|
toTTL: 0,
|
|
expected: sql.NullInt64{Valid: true, Int64: int64(24 * time.Hour)},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
var (
|
|
logger = slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug)
|
|
db, _ = dbtestutil.NewDB(t)
|
|
ctx = testutil.Context(t, testutil.WaitLong)
|
|
user = dbgen.User(t, db, database.User{})
|
|
file = dbgen.File(t, db, database.File{CreatedBy: user.ID})
|
|
// Create first template
|
|
templateJob = dbgen.ProvisionerJob(t, db, nil, database.ProvisionerJob{
|
|
FileID: file.ID,
|
|
InitiatorID: user.ID,
|
|
Tags: database.StringMap{"foo": "bar"},
|
|
})
|
|
templateVersion = dbgen.TemplateVersion(t, db, database.TemplateVersion{
|
|
CreatedBy: user.ID,
|
|
JobID: templateJob.ID,
|
|
OrganizationID: templateJob.OrganizationID,
|
|
})
|
|
template = dbgen.Template(t, db, database.Template{
|
|
ActiveVersionID: templateVersion.ID,
|
|
CreatedBy: user.ID,
|
|
OrganizationID: templateJob.OrganizationID,
|
|
AllowUserAutostop: false,
|
|
})
|
|
// Create second template
|
|
otherTTL = tt.fromTTL + 6*time.Hour
|
|
otherTemplateJob = dbgen.ProvisionerJob(t, db, nil, database.ProvisionerJob{
|
|
FileID: file.ID,
|
|
InitiatorID: user.ID,
|
|
Tags: database.StringMap{"foo": "bar"},
|
|
})
|
|
otherTemplateVersion = dbgen.TemplateVersion(t, db, database.TemplateVersion{
|
|
CreatedBy: user.ID,
|
|
JobID: otherTemplateJob.ID,
|
|
OrganizationID: otherTemplateJob.OrganizationID,
|
|
})
|
|
otherTemplate = dbgen.Template(t, db, database.Template{
|
|
ActiveVersionID: otherTemplateVersion.ID,
|
|
CreatedBy: user.ID,
|
|
OrganizationID: otherTemplateJob.OrganizationID,
|
|
AllowUserAutostop: false,
|
|
})
|
|
)
|
|
|
|
// Setup the template schedule store
|
|
notifyEnq := notifications.NewNoopEnqueuer()
|
|
const userQuietHoursSchedule = "CRON_TZ=UTC 0 0 * * *" // midnight UTC
|
|
userQuietHoursStore, err := schedule.NewEnterpriseUserQuietHoursScheduleStore(userQuietHoursSchedule, true)
|
|
require.NoError(t, err)
|
|
userQuietHoursStorePtr := &atomic.Pointer[agplschedule.UserQuietHoursScheduleStore]{}
|
|
userQuietHoursStorePtr.Store(&userQuietHoursStore)
|
|
templateScheduleStore := schedule.NewEnterpriseTemplateScheduleStore(userQuietHoursStorePtr, notifyEnq, logger, nil)
|
|
|
|
// Set both template's default TTL
|
|
template, err = templateScheduleStore.Set(ctx, db, template, agplschedule.TemplateScheduleOptions{
|
|
DefaultTTL: tt.fromTTL,
|
|
})
|
|
require.NoError(t, err)
|
|
otherTemplate, err = templateScheduleStore.Set(ctx, db, otherTemplate, agplschedule.TemplateScheduleOptions{
|
|
DefaultTTL: otherTTL,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
// We create two workspaces here, one with the template we're modifying, the
|
|
// other with a different template. We want to ensure we only modify one
|
|
// of the workspaces.
|
|
workspace := dbgen.Workspace(t, db, database.WorkspaceTable{
|
|
OwnerID: user.ID,
|
|
TemplateID: template.ID,
|
|
OrganizationID: templateJob.OrganizationID,
|
|
LastUsedAt: dbtime.Now(),
|
|
Ttl: sql.NullInt64{Valid: true, Int64: int64(tt.fromTTL)},
|
|
})
|
|
otherWorkspace := dbgen.Workspace(t, db, database.WorkspaceTable{
|
|
OwnerID: user.ID,
|
|
TemplateID: otherTemplate.ID,
|
|
OrganizationID: otherTemplateJob.OrganizationID,
|
|
LastUsedAt: dbtime.Now(),
|
|
Ttl: sql.NullInt64{Valid: true, Int64: int64(otherTTL)},
|
|
})
|
|
|
|
// Ensure the workspace's start with the correct TTLs
|
|
require.Equal(t, sql.NullInt64{Valid: true, Int64: int64(tt.fromTTL)}, workspace.Ttl)
|
|
require.Equal(t, sql.NullInt64{Valid: true, Int64: int64(otherTTL)}, otherWorkspace.Ttl)
|
|
|
|
// Update _only_ the primary template's TTL
|
|
_, err = templateScheduleStore.Set(ctx, db, template, agplschedule.TemplateScheduleOptions{
|
|
UserAutostopEnabled: tt.allowUserAutostop,
|
|
DefaultTTL: tt.toTTL,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
// Verify the primary workspace's TTL is what we expect
|
|
ws, err := db.GetWorkspaceByID(ctx, workspace.ID)
|
|
require.NoError(t, err)
|
|
require.Equal(t, tt.expected, ws.Ttl)
|
|
|
|
// Verify we haven't changed the other workspace's TTL
|
|
ws, err = db.GetWorkspaceByID(ctx, otherWorkspace.ID)
|
|
require.NoError(t, err)
|
|
require.Equal(t, sql.NullInt64{Valid: true, Int64: int64(otherTTL)}, ws.Ttl)
|
|
})
|
|
}
|
|
|
|
t.Run("WorkspaceTTLUpdatedWhenAllowUserAutostopGetsDisabled", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
var (
|
|
logger = slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug)
|
|
db, _ = dbtestutil.NewDB(t)
|
|
ctx = testutil.Context(t, testutil.WaitLong)
|
|
user = dbgen.User(t, db, database.User{})
|
|
file = dbgen.File(t, db, database.File{CreatedBy: user.ID})
|
|
// Create first template
|
|
templateJob = dbgen.ProvisionerJob(t, db, nil, database.ProvisionerJob{
|
|
FileID: file.ID,
|
|
InitiatorID: user.ID,
|
|
Tags: database.StringMap{"foo": "bar"},
|
|
})
|
|
templateVersion = dbgen.TemplateVersion(t, db, database.TemplateVersion{
|
|
CreatedBy: user.ID,
|
|
JobID: templateJob.ID,
|
|
OrganizationID: templateJob.OrganizationID,
|
|
})
|
|
template = dbgen.Template(t, db, database.Template{
|
|
ActiveVersionID: templateVersion.ID,
|
|
CreatedBy: user.ID,
|
|
OrganizationID: templateJob.OrganizationID,
|
|
})
|
|
)
|
|
|
|
// Setup the template schedule store
|
|
notifyEnq := notifications.NewNoopEnqueuer()
|
|
const userQuietHoursSchedule = "CRON_TZ=UTC 0 0 * * *" // midnight UTC
|
|
userQuietHoursStore, err := schedule.NewEnterpriseUserQuietHoursScheduleStore(userQuietHoursSchedule, true)
|
|
require.NoError(t, err)
|
|
userQuietHoursStorePtr := &atomic.Pointer[agplschedule.UserQuietHoursScheduleStore]{}
|
|
userQuietHoursStorePtr.Store(&userQuietHoursStore)
|
|
templateScheduleStore := schedule.NewEnterpriseTemplateScheduleStore(userQuietHoursStorePtr, notifyEnq, logger, nil)
|
|
|
|
// Enable AllowUserAutostop
|
|
template, err = templateScheduleStore.Set(ctx, db, template, agplschedule.TemplateScheduleOptions{
|
|
DefaultTTL: 24 * time.Hour,
|
|
UserAutostopEnabled: true,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
// Create a workspace with a TTL different than the template's default TTL
|
|
workspace := dbgen.Workspace(t, db, database.WorkspaceTable{
|
|
OwnerID: user.ID,
|
|
TemplateID: template.ID,
|
|
OrganizationID: templateJob.OrganizationID,
|
|
LastUsedAt: dbtime.Now(),
|
|
Ttl: sql.NullInt64{Valid: true, Int64: int64(48 * time.Hour)},
|
|
})
|
|
|
|
// Ensure the workspace start with the correct TTLs
|
|
require.Equal(t, sql.NullInt64{Valid: true, Int64: int64(48 * time.Hour)}, workspace.Ttl)
|
|
|
|
// Disable AllowUserAutostop
|
|
template, err = templateScheduleStore.Set(ctx, db, template, agplschedule.TemplateScheduleOptions{
|
|
DefaultTTL: 23 * time.Hour,
|
|
UserAutostopEnabled: false,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
// Ensure the workspace ends with the correct TTLs
|
|
ws, err := db.GetWorkspaceByID(ctx, workspace.ID)
|
|
require.NoError(t, err)
|
|
require.Equal(t, sql.NullInt64{Valid: true, Int64: int64(23 * time.Hour)}, ws.Ttl)
|
|
})
|
|
}
|
|
|
|
func TestTemplateUpdatePrebuilds(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// Dormant auto-delete configured to 10 hours
|
|
dormantAutoDelete := 10 * time.Hour
|
|
|
|
// TTL configured to 8 hours
|
|
ttl := 8 * time.Hour
|
|
|
|
// Autostop configuration set to everyday at midnight
|
|
autostopWeekdays, err := codersdk.WeekdaysToBitmap(codersdk.AllDaysOfWeek)
|
|
require.NoError(t, err)
|
|
|
|
// Autostart configuration set to everyday at midnight
|
|
autostartSchedule, err := cron.Weekly("CRON_TZ=UTC 0 0 * * *")
|
|
require.NoError(t, err)
|
|
autostartWeekdays, err := codersdk.WeekdaysToBitmap(codersdk.AllDaysOfWeek)
|
|
require.NoError(t, err)
|
|
|
|
cases := []struct {
|
|
name string
|
|
templateSchedule agplschedule.TemplateScheduleOptions
|
|
workspaceUpdate func(*testing.T, context.Context, database.Store, time.Time, database.ClaimPrebuiltWorkspaceRow)
|
|
assertWorkspace func(*testing.T, context.Context, database.Store, time.Time, bool, database.Workspace)
|
|
}{
|
|
{
|
|
name: "TemplateDormantAutoDeleteUpdatePrebuildAfterClaim",
|
|
templateSchedule: agplschedule.TemplateScheduleOptions{
|
|
// Template level TimeTilDormantAutodelete set to 10 hours
|
|
TimeTilDormantAutoDelete: dormantAutoDelete,
|
|
},
|
|
workspaceUpdate: func(t *testing.T, ctx context.Context, db database.Store, now time.Time,
|
|
workspace database.ClaimPrebuiltWorkspaceRow,
|
|
) {
|
|
// When: the workspace is marked dormant
|
|
dormantWorkspace, err := db.UpdateWorkspaceDormantDeletingAt(ctx, database.UpdateWorkspaceDormantDeletingAtParams{
|
|
ID: workspace.ID,
|
|
DormantAt: sql.NullTime{
|
|
Time: now,
|
|
Valid: true,
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
require.NotNil(t, dormantWorkspace.DormantAt)
|
|
},
|
|
assertWorkspace: func(t *testing.T, ctx context.Context, db database.Store, now time.Time,
|
|
isPrebuild bool, workspace database.Workspace,
|
|
) {
|
|
if isPrebuild {
|
|
// The unclaimed prebuild should have an empty DormantAt and DeletingAt
|
|
require.True(t, workspace.DormantAt.Time.IsZero())
|
|
require.True(t, workspace.DeletingAt.Time.IsZero())
|
|
} else {
|
|
// The claimed workspace should have its DormantAt and DeletingAt updated
|
|
require.False(t, workspace.DormantAt.Time.IsZero())
|
|
require.False(t, workspace.DeletingAt.Time.IsZero())
|
|
require.WithinDuration(t, now.UTC(), workspace.DormantAt.Time.UTC(), time.Second)
|
|
require.WithinDuration(t, now.Add(dormantAutoDelete).UTC(), workspace.DeletingAt.Time.UTC(), time.Second)
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: "TemplateTTLUpdatePrebuildAfterClaim",
|
|
templateSchedule: agplschedule.TemplateScheduleOptions{
|
|
// Template level TTL can only be set if autostop is disabled for users
|
|
DefaultTTL: ttl,
|
|
UserAutostopEnabled: false,
|
|
},
|
|
workspaceUpdate: func(t *testing.T, ctx context.Context, db database.Store, now time.Time,
|
|
workspace database.ClaimPrebuiltWorkspaceRow) {
|
|
},
|
|
assertWorkspace: func(t *testing.T, ctx context.Context, db database.Store, now time.Time,
|
|
isPrebuild bool, workspace database.Workspace,
|
|
) {
|
|
if isPrebuild {
|
|
// The unclaimed prebuild should have an empty TTL
|
|
require.Equal(t, sql.NullInt64{}, workspace.Ttl)
|
|
} else {
|
|
// The claimed workspace should have its TTL updated
|
|
require.Equal(t, sql.NullInt64{Int64: int64(ttl), Valid: true}, workspace.Ttl)
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: "TemplateAutostopUpdatePrebuildAfterClaim",
|
|
templateSchedule: agplschedule.TemplateScheduleOptions{
|
|
// Template level Autostop set for everyday
|
|
AutostopRequirement: agplschedule.TemplateAutostopRequirement{
|
|
DaysOfWeek: autostopWeekdays,
|
|
Weeks: 0,
|
|
},
|
|
},
|
|
workspaceUpdate: func(t *testing.T, ctx context.Context, db database.Store, now time.Time,
|
|
workspace database.ClaimPrebuiltWorkspaceRow) {
|
|
},
|
|
assertWorkspace: func(t *testing.T, ctx context.Context, db database.Store, now time.Time, isPrebuild bool, workspace database.Workspace) {
|
|
if isPrebuild {
|
|
// The unclaimed prebuild should have an empty MaxDeadline
|
|
prebuildBuild, err := db.GetLatestWorkspaceBuildByWorkspaceID(ctx, workspace.ID)
|
|
require.NoError(t, err)
|
|
require.True(t, prebuildBuild.MaxDeadline.IsZero())
|
|
} else {
|
|
// The claimed workspace should have its MaxDeadline updated
|
|
workspaceBuild, err := db.GetLatestWorkspaceBuildByWorkspaceID(ctx, workspace.ID)
|
|
require.NoError(t, err)
|
|
require.False(t, workspaceBuild.MaxDeadline.IsZero())
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: "TemplateAutostartUpdatePrebuildAfterClaim",
|
|
templateSchedule: agplschedule.TemplateScheduleOptions{
|
|
// Template level Autostart set for everyday
|
|
UserAutostartEnabled: true,
|
|
AutostartRequirement: agplschedule.TemplateAutostartRequirement{
|
|
DaysOfWeek: autostartWeekdays,
|
|
},
|
|
},
|
|
workspaceUpdate: func(t *testing.T, ctx context.Context, db database.Store, now time.Time, workspace database.ClaimPrebuiltWorkspaceRow) {
|
|
// To compute NextStartAt, the workspace must have a valid autostart schedule
|
|
err = db.UpdateWorkspaceAutostart(ctx, database.UpdateWorkspaceAutostartParams{
|
|
ID: workspace.ID,
|
|
AutostartSchedule: sql.NullString{
|
|
String: autostartSchedule.String(),
|
|
Valid: true,
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
},
|
|
assertWorkspace: func(t *testing.T, ctx context.Context, db database.Store, now time.Time, isPrebuild bool, workspace database.Workspace) {
|
|
if isPrebuild {
|
|
// The unclaimed prebuild should have an empty NextStartAt
|
|
require.True(t, workspace.NextStartAt.Time.IsZero())
|
|
} else {
|
|
// The claimed workspace should have its NextStartAt updated
|
|
require.False(t, workspace.NextStartAt.Time.IsZero())
|
|
}
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
clock := quartz.NewMock(t)
|
|
clock.Set(dbtime.Now())
|
|
|
|
// Setup
|
|
var (
|
|
logger = slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug)
|
|
db, _ = dbtestutil.NewDB(t, dbtestutil.WithDumpOnFailure())
|
|
ctx = testutil.Context(t, testutil.WaitLong)
|
|
user = dbgen.User(t, db, database.User{})
|
|
)
|
|
|
|
// Setup the template schedule store
|
|
notifyEnq := notifications.NewNoopEnqueuer()
|
|
const userQuietHoursSchedule = "CRON_TZ=UTC 0 0 * * *" // midnight UTC
|
|
userQuietHoursStore, err := schedule.NewEnterpriseUserQuietHoursScheduleStore(userQuietHoursSchedule, true)
|
|
require.NoError(t, err)
|
|
userQuietHoursStorePtr := &atomic.Pointer[agplschedule.UserQuietHoursScheduleStore]{}
|
|
userQuietHoursStorePtr.Store(&userQuietHoursStore)
|
|
templateScheduleStore := schedule.NewEnterpriseTemplateScheduleStore(userQuietHoursStorePtr, notifyEnq, logger, clock)
|
|
|
|
// Given: a template and a template version with preset and a prebuilt workspace
|
|
presetID := uuid.New()
|
|
org := dbfake.Organization(t, db).Do()
|
|
tv := dbfake.TemplateVersion(t, db).Seed(database.TemplateVersion{
|
|
OrganizationID: org.Org.ID,
|
|
CreatedBy: user.ID,
|
|
}).Preset(database.TemplateVersionPreset{
|
|
ID: presetID,
|
|
DesiredInstances: sql.NullInt32{
|
|
Int32: 1,
|
|
Valid: true,
|
|
},
|
|
}).Do()
|
|
workspaceBuild := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{
|
|
OwnerID: database.PrebuildsSystemUserID,
|
|
TemplateID: tv.Template.ID,
|
|
OrganizationID: tv.Template.OrganizationID,
|
|
}).Seed(database.WorkspaceBuild{
|
|
TemplateVersionID: tv.TemplateVersion.ID,
|
|
TemplateVersionPresetID: uuid.NullUUID{
|
|
UUID: presetID,
|
|
Valid: true,
|
|
},
|
|
}).WithAgent(func(agent []*proto.Agent) []*proto.Agent {
|
|
return agent
|
|
}).Do()
|
|
|
|
// Mark the prebuilt workspace's agent as ready so the prebuild can be claimed
|
|
agentCtx := testutil.Context(t, testutil.WaitLong)
|
|
agent, err := db.GetAuthenticatedWorkspaceAgentAndBuildByAuthToken(agentCtx, uuid.MustParse(workspaceBuild.AgentToken))
|
|
require.NoError(t, err)
|
|
err = db.UpdateWorkspaceAgentLifecycleStateByID(agentCtx, database.UpdateWorkspaceAgentLifecycleStateByIDParams{
|
|
ID: agent.WorkspaceAgent.ID,
|
|
LifecycleState: database.WorkspaceAgentLifecycleStateReady,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
// Given: a prebuilt workspace
|
|
prebuild, err := db.GetWorkspaceByID(ctx, workspaceBuild.Workspace.ID)
|
|
require.NoError(t, err)
|
|
tc.assertWorkspace(t, ctx, db, clock.Now(), true, prebuild)
|
|
|
|
// When: the template schedule is updated
|
|
_, err = templateScheduleStore.Set(ctx, db, tv.Template, tc.templateSchedule)
|
|
require.NoError(t, err)
|
|
|
|
// Then: lifecycle parameters must remain unset while the prebuild is unclaimed
|
|
prebuild, err = db.GetWorkspaceByID(ctx, workspaceBuild.Workspace.ID)
|
|
require.NoError(t, err)
|
|
tc.assertWorkspace(t, ctx, db, clock.Now(), true, prebuild)
|
|
|
|
// Given: the prebuilt workspace is claimed by a user
|
|
claimedWorkspace := dbgen.ClaimPrebuild(
|
|
t, db,
|
|
clock.Now(),
|
|
user.ID,
|
|
"claimedWorkspace-autostop",
|
|
presetID,
|
|
sql.NullString{},
|
|
sql.NullTime{},
|
|
sql.NullInt64{})
|
|
require.Equal(t, prebuild.ID, claimedWorkspace.ID)
|
|
|
|
// Given: the workspace level configurations are properly set in order to ensure the
|
|
// lifecycle parameters are updated
|
|
tc.workspaceUpdate(t, ctx, db, clock.Now(), claimedWorkspace)
|
|
|
|
// When: the template schedule is updated
|
|
_, err = templateScheduleStore.Set(ctx, db, tv.Template, tc.templateSchedule)
|
|
require.NoError(t, err)
|
|
|
|
// Then: the workspace should have its lifecycle parameters updated
|
|
workspace, err := db.GetWorkspaceByID(ctx, claimedWorkspace.ID)
|
|
require.NoError(t, err)
|
|
tc.assertWorkspace(t, ctx, db, clock.Now(), false, workspace)
|
|
})
|
|
}
|
|
}
|
|
|
|
func must[V any](v V, err error) V {
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
return v
|
|
}
|