mirror of https://github.com/coder/coder.git
320 lines
13 KiB
Go
320 lines
13 KiB
Go
package agentapi_test
|
|
|
|
import (
|
|
"database/sql"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"cdr.dev/slog/sloggers/slogtest"
|
|
"github.com/coder/coder/v2/coderd/agentapi"
|
|
"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/util/ptr"
|
|
"github.com/coder/coder/v2/testutil"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
func Test_ActivityBumpWorkspace(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// We test the below in multiple timezones specifically
|
|
// chosen to trigger timezone-related bugs.
|
|
timezones := []string{
|
|
"Asia/Kolkata", // No DST, positive fractional offset
|
|
"Canada/Newfoundland", // DST, negative fractional offset
|
|
"Europe/Paris", // DST, positive offset
|
|
"US/Arizona", // No DST, negative offset
|
|
"UTC", // Baseline
|
|
}
|
|
|
|
for _, tt := range []struct {
|
|
name string
|
|
transition database.WorkspaceTransition
|
|
jobCompletedAt sql.NullTime
|
|
buildDeadlineOffset *time.Duration
|
|
maxDeadlineOffset *time.Duration
|
|
workspaceTTL time.Duration
|
|
templateTTL time.Duration
|
|
templateActivityBump time.Duration
|
|
templateDisallowsUserAutostop bool
|
|
expectedBump time.Duration
|
|
// If the tests get queued, we need to be able to set the next autostart
|
|
// based on the actual time the unit test is running.
|
|
nextAutostart func(now time.Time) time.Time
|
|
}{
|
|
{
|
|
name: "NotFinishedYet",
|
|
transition: database.WorkspaceTransitionStart,
|
|
jobCompletedAt: sql.NullTime{},
|
|
buildDeadlineOffset: ptr.Ref(8 * time.Hour),
|
|
workspaceTTL: 8 * time.Hour,
|
|
expectedBump: 0,
|
|
},
|
|
{
|
|
name: "ManualShutdown",
|
|
transition: database.WorkspaceTransitionStart,
|
|
jobCompletedAt: sql.NullTime{Valid: true, Time: dbtime.Now()},
|
|
buildDeadlineOffset: nil,
|
|
expectedBump: 0,
|
|
},
|
|
{
|
|
name: "NotTimeToBumpYet",
|
|
transition: database.WorkspaceTransitionStart,
|
|
jobCompletedAt: sql.NullTime{Valid: true, Time: dbtime.Now()},
|
|
buildDeadlineOffset: ptr.Ref(8 * time.Hour),
|
|
workspaceTTL: 8 * time.Hour,
|
|
expectedBump: 0,
|
|
},
|
|
{
|
|
// Expected bump is 0 because the original deadline is more than 1 hour
|
|
// out, so a bump would decrease the deadline.
|
|
name: "BumpLessThanDeadline",
|
|
transition: database.WorkspaceTransitionStart,
|
|
jobCompletedAt: sql.NullTime{Valid: true, Time: dbtime.Now().Add(-30 * time.Minute)},
|
|
buildDeadlineOffset: ptr.Ref(8*time.Hour - 30*time.Minute),
|
|
workspaceTTL: 8 * time.Hour,
|
|
expectedBump: 0,
|
|
},
|
|
{
|
|
name: "TimeToBump",
|
|
transition: database.WorkspaceTransitionStart,
|
|
jobCompletedAt: sql.NullTime{Valid: true, Time: dbtime.Now().Add(-30 * time.Minute)},
|
|
buildDeadlineOffset: ptr.Ref(-30 * time.Minute),
|
|
workspaceTTL: 8 * time.Hour,
|
|
expectedBump: time.Hour,
|
|
},
|
|
{
|
|
name: "TimeToBumpNextAutostart",
|
|
transition: database.WorkspaceTransitionStart,
|
|
jobCompletedAt: sql.NullTime{Valid: true, Time: dbtime.Now().Add(-30 * time.Minute)},
|
|
buildDeadlineOffset: ptr.Ref(-30 * time.Minute),
|
|
workspaceTTL: 8 * time.Hour,
|
|
expectedBump: 8*time.Hour + 30*time.Minute,
|
|
nextAutostart: func(now time.Time) time.Time { return now.Add(time.Minute * 30) },
|
|
},
|
|
{
|
|
name: "MaxDeadline",
|
|
transition: database.WorkspaceTransitionStart,
|
|
jobCompletedAt: sql.NullTime{Valid: true, Time: dbtime.Now().Add(-24 * time.Minute)},
|
|
buildDeadlineOffset: ptr.Ref(time.Minute), // last chance to bump!
|
|
maxDeadlineOffset: ptr.Ref(time.Minute * 30),
|
|
workspaceTTL: 8 * time.Hour,
|
|
expectedBump: time.Minute * 30,
|
|
},
|
|
{
|
|
// A workspace that is still running, has passed its deadline, but has not
|
|
// yet been auto-stopped should still bump the deadline.
|
|
name: "PastDeadlineStillBumps",
|
|
transition: database.WorkspaceTransitionStart,
|
|
jobCompletedAt: sql.NullTime{Valid: true, Time: dbtime.Now().Add(-24 * time.Minute)},
|
|
buildDeadlineOffset: ptr.Ref(-time.Minute),
|
|
workspaceTTL: 8 * time.Hour,
|
|
expectedBump: time.Hour,
|
|
},
|
|
{
|
|
// A stopped workspace should never bump.
|
|
name: "StoppedWorkspace",
|
|
transition: database.WorkspaceTransitionStop,
|
|
jobCompletedAt: sql.NullTime{Valid: true, Time: dbtime.Now().Add(-time.Minute)},
|
|
buildDeadlineOffset: ptr.Ref(-time.Minute),
|
|
workspaceTTL: 8 * time.Hour,
|
|
},
|
|
{
|
|
// A workspace built from a template that disallows user autostop should bump
|
|
// by the template TTL instead.
|
|
name: "TemplateDisallowsUserAutostop",
|
|
transition: database.WorkspaceTransitionStart,
|
|
jobCompletedAt: sql.NullTime{Valid: true, Time: dbtime.Now().Add(-3 * time.Hour)},
|
|
buildDeadlineOffset: ptr.Ref(-30 * time.Minute),
|
|
workspaceTTL: 2 * time.Hour,
|
|
templateTTL: 10 * time.Hour,
|
|
templateDisallowsUserAutostop: true,
|
|
expectedBump: 10*time.Hour + (time.Minute * 30),
|
|
nextAutostart: func(now time.Time) time.Time { return now.Add(time.Minute * 30) },
|
|
},
|
|
{
|
|
// Custom activity bump duration specified on the template.
|
|
name: "TemplateCustomActivityBump",
|
|
transition: database.WorkspaceTransitionStart,
|
|
jobCompletedAt: sql.NullTime{Valid: true, Time: dbtime.Now().Add(-30 * time.Minute)},
|
|
buildDeadlineOffset: ptr.Ref(-30 * time.Minute),
|
|
workspaceTTL: 8 * time.Hour,
|
|
templateActivityBump: 5 * time.Hour, // instead of default 1h
|
|
expectedBump: 5 * time.Hour,
|
|
},
|
|
{
|
|
// Activity bump duration is 0.
|
|
name: "TemplateCustomActivityBumpZero",
|
|
transition: database.WorkspaceTransitionStart,
|
|
jobCompletedAt: sql.NullTime{Valid: true, Time: dbtime.Now().Add(-30 * time.Minute)},
|
|
buildDeadlineOffset: ptr.Ref(-30 * time.Minute),
|
|
workspaceTTL: 8 * time.Hour,
|
|
templateActivityBump: -1, // negative values get changed to 0 in the test
|
|
expectedBump: 0,
|
|
},
|
|
} {
|
|
tt := tt
|
|
for _, tz := range timezones {
|
|
tz := tz
|
|
t.Run(tt.name+"/"+tz, func(t *testing.T) {
|
|
t.Parallel()
|
|
nextAutostart := tt.nextAutostart
|
|
if tt.nextAutostart == nil {
|
|
nextAutostart = func(now time.Time) time.Time { return time.Time{} }
|
|
}
|
|
|
|
var (
|
|
now = dbtime.Now()
|
|
ctx = testutil.Context(t, testutil.WaitShort)
|
|
log = slogtest.Make(t, nil)
|
|
db, _ = dbtestutil.NewDB(t, dbtestutil.WithTimezone(tz))
|
|
org = dbgen.Organization(t, db, database.Organization{})
|
|
user = dbgen.User(t, db, database.User{
|
|
Status: database.UserStatusActive,
|
|
})
|
|
_ = dbgen.OrganizationMember(t, db, database.OrganizationMember{
|
|
UserID: user.ID,
|
|
OrganizationID: org.ID,
|
|
})
|
|
templateVersion = dbgen.TemplateVersion(t, db, database.TemplateVersion{
|
|
OrganizationID: org.ID,
|
|
CreatedBy: user.ID,
|
|
})
|
|
template = dbgen.Template(t, db, database.Template{
|
|
OrganizationID: org.ID,
|
|
ActiveVersionID: templateVersion.ID,
|
|
CreatedBy: user.ID,
|
|
})
|
|
ws = dbgen.Workspace(t, db, database.Workspace{
|
|
OwnerID: user.ID,
|
|
OrganizationID: org.ID,
|
|
TemplateID: template.ID,
|
|
Ttl: sql.NullInt64{Valid: true, Int64: int64(tt.workspaceTTL)},
|
|
})
|
|
job = dbgen.ProvisionerJob(t, db, nil, database.ProvisionerJob{
|
|
OrganizationID: org.ID,
|
|
CompletedAt: tt.jobCompletedAt,
|
|
})
|
|
_ = dbgen.WorkspaceResource(t, db, database.WorkspaceResource{
|
|
JobID: job.ID,
|
|
})
|
|
buildID = uuid.New()
|
|
)
|
|
|
|
activityBump := 1 * time.Hour
|
|
if tt.templateActivityBump < 0 {
|
|
// less than 0 => 0
|
|
activityBump = 0
|
|
} else if tt.templateActivityBump != 0 {
|
|
activityBump = tt.templateActivityBump
|
|
}
|
|
require.NoError(t, db.UpdateTemplateScheduleByID(ctx, database.UpdateTemplateScheduleByIDParams{
|
|
ID: template.ID,
|
|
UpdatedAt: dbtime.Now(),
|
|
AllowUserAutostop: !tt.templateDisallowsUserAutostop,
|
|
DefaultTTL: int64(tt.templateTTL),
|
|
ActivityBump: int64(activityBump),
|
|
}), "unexpected error updating template schedule")
|
|
|
|
var buildNumber int32 = 1
|
|
// Insert a number of previous workspace builds.
|
|
for i := 0; i < 5; i++ {
|
|
insertPrevWorkspaceBuild(t, db, org.ID, templateVersion.ID, ws.ID, database.WorkspaceTransitionStart, buildNumber)
|
|
buildNumber++
|
|
insertPrevWorkspaceBuild(t, db, org.ID, templateVersion.ID, ws.ID, database.WorkspaceTransitionStop, buildNumber)
|
|
buildNumber++
|
|
}
|
|
|
|
// dbgen.WorkspaceBuild automatically sets deadline to now+1 hour if not set
|
|
var buildDeadline time.Time
|
|
if tt.buildDeadlineOffset != nil {
|
|
buildDeadline = now.Add(*tt.buildDeadlineOffset)
|
|
}
|
|
var maxDeadline time.Time
|
|
if tt.maxDeadlineOffset != nil {
|
|
maxDeadline = now.Add(*tt.maxDeadlineOffset)
|
|
}
|
|
err := db.InsertWorkspaceBuild(ctx, database.InsertWorkspaceBuildParams{
|
|
ID: buildID,
|
|
CreatedAt: dbtime.Now(),
|
|
UpdatedAt: dbtime.Now(),
|
|
BuildNumber: buildNumber,
|
|
InitiatorID: user.ID,
|
|
Reason: database.BuildReasonInitiator,
|
|
WorkspaceID: ws.ID,
|
|
JobID: job.ID,
|
|
TemplateVersionID: templateVersion.ID,
|
|
Transition: tt.transition,
|
|
Deadline: buildDeadline,
|
|
MaxDeadline: maxDeadline,
|
|
})
|
|
require.NoError(t, err, "unexpected error inserting workspace build")
|
|
bld, err := db.GetWorkspaceBuildByID(ctx, buildID)
|
|
require.NoError(t, err, "unexpected error fetching inserted workspace build")
|
|
|
|
// Validate our initial state before bump
|
|
require.Equal(t, tt.transition, bld.Transition, "unexpected transition before bump")
|
|
require.Equal(t, tt.jobCompletedAt.Time.UTC(), job.CompletedAt.Time.UTC(), "unexpected job completed at before bump")
|
|
require.Equal(t, buildDeadline.UTC(), bld.Deadline.UTC(), "unexpected build deadline before bump")
|
|
require.Equal(t, maxDeadline.UTC(), bld.MaxDeadline.UTC(), "unexpected max deadline before bump")
|
|
require.Equal(t, tt.workspaceTTL, time.Duration(ws.Ttl.Int64), "unexpected workspace TTL before bump")
|
|
|
|
// Wait a bit before bumping as dbtime is rounded to the nearest millisecond.
|
|
// This should also hopefully be enough for Windows time resolution to register
|
|
// a tick (win32 max timer resolution is apparently between 0.5 and 15.6ms)
|
|
<-time.After(testutil.IntervalFast)
|
|
|
|
// Bump duration is measured from the time of the bump, so we measure from here.
|
|
start := dbtime.Now()
|
|
agentapi.ActivityBumpWorkspace(ctx, log, db, bld.WorkspaceID, nextAutostart(start))
|
|
end := dbtime.Now()
|
|
|
|
// Validate our state after bump
|
|
updatedBuild, err := db.GetLatestWorkspaceBuildByWorkspaceID(ctx, bld.WorkspaceID)
|
|
require.NoError(t, err, "unexpected error getting latest workspace build")
|
|
require.Equal(t, bld.MaxDeadline.UTC(), updatedBuild.MaxDeadline.UTC(), "max_deadline should not have changed")
|
|
if tt.expectedBump == 0 {
|
|
assert.Equal(t, bld.UpdatedAt.UTC(), updatedBuild.UpdatedAt.UTC(), "should not have bumped updated_at")
|
|
assert.Equal(t, bld.Deadline.UTC(), updatedBuild.Deadline.UTC(), "should not have bumped deadline")
|
|
return
|
|
}
|
|
assert.NotEqual(t, bld.UpdatedAt.UTC(), updatedBuild.UpdatedAt.UTC(), "should have bumped updated_at")
|
|
if tt.maxDeadlineOffset != nil {
|
|
assert.Equal(t, bld.MaxDeadline.UTC(), updatedBuild.MaxDeadline.UTC(), "new deadline must equal original max deadline")
|
|
return
|
|
}
|
|
|
|
// Assert that the bump occurred between start and end. 1min buffer on either side.
|
|
expectedDeadlineStart := start.Add(tt.expectedBump).Add(time.Minute * -1)
|
|
expectedDeadlineEnd := end.Add(tt.expectedBump).Add(time.Minute)
|
|
require.GreaterOrEqual(t, updatedBuild.Deadline, expectedDeadlineStart, "new deadline should be greater than or equal to start")
|
|
require.LessOrEqual(t, updatedBuild.Deadline, expectedDeadlineEnd, "new deadline should be less than or equal to end")
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
func insertPrevWorkspaceBuild(t *testing.T, db database.Store, orgID, tvID, workspaceID uuid.UUID, transition database.WorkspaceTransition, buildNumber int32) {
|
|
t.Helper()
|
|
|
|
job := dbgen.ProvisionerJob(t, db, nil, database.ProvisionerJob{
|
|
OrganizationID: orgID,
|
|
})
|
|
_ = dbgen.WorkspaceResource(t, db, database.WorkspaceResource{
|
|
JobID: job.ID,
|
|
})
|
|
_ = dbgen.WorkspaceBuild(t, db, database.WorkspaceBuild{
|
|
BuildNumber: buildNumber,
|
|
WorkspaceID: workspaceID,
|
|
JobID: job.ID,
|
|
TemplateVersionID: tvID,
|
|
Transition: transition,
|
|
})
|
|
}
|