chore: improve build deadline code (#19203)

- Adds/improves a lot of comments to make the autostop calculation code
clearer
- Changes the behavior of the enterprise template schedule store to
match the behavior of the workspace TTL endpoint when the new TTL is
zero
- Fixes a bug in the workspace TTL endpoint where it could unset the
build deadline, even though a max_deadline was specified
- Adds a new constraint to the workspace_builds table that enforces the
deadline is non-zero and below the max_deadline if it is set
- Adds CHECK constraint enum generation to scripts/dbgen, used for
testing the above constraint
- Adds Dean and Danielle as CODEOWNERS for the autostop calculation code
This commit is contained in:
Dean Sheather
2025-08-07 11:00:31 +10:00
committed by GitHub
parent ec3b8cea91
commit dc598856e3
16 changed files with 660 additions and 258 deletions
+16
View File
@@ -0,0 +1,16 @@
// Code generated by scripts/dbgen/main.go. DO NOT EDIT.
package database
// CheckConstraint represents a named check constraint on a table.
type CheckConstraint string
// CheckConstraint enums.
const (
CheckOneTimePasscodeSet CheckConstraint = "one_time_passcode_set" // users
CheckMaxProvisionerLogsLength CheckConstraint = "max_provisioner_logs_length" // provisioner_jobs
CheckValidationMonotonicOrder CheckConstraint = "validation_monotonic_order" // template_version_parameters
CheckMaxLogsLength CheckConstraint = "max_logs_length" // workspace_agents
CheckSubsystemsNotNone CheckConstraint = "subsystems_not_none" // workspace_agents
CheckWorkspaceBuildsAiTaskSidebarAppIDRequired CheckConstraint = "workspace_builds_ai_task_sidebar_app_id_required" // workspace_builds
CheckWorkspaceBuildsDeadlineBelowMaxDeadline CheckConstraint = "workspace_builds_deadline_below_max_deadline" // workspace_builds
)
+2 -1
View File
@@ -2224,7 +2224,8 @@ CREATE TABLE workspace_builds (
template_version_preset_id uuid,
has_ai_task boolean,
ai_task_sidebar_app_id uuid,
CONSTRAINT workspace_builds_ai_task_sidebar_app_id_required CHECK (((((has_ai_task IS NULL) OR (has_ai_task = false)) AND (ai_task_sidebar_app_id IS NULL)) OR ((has_ai_task = true) AND (ai_task_sidebar_app_id IS NOT NULL))))
CONSTRAINT workspace_builds_ai_task_sidebar_app_id_required CHECK (((((has_ai_task IS NULL) OR (has_ai_task = false)) AND (ai_task_sidebar_app_id IS NULL)) OR ((has_ai_task = true) AND (ai_task_sidebar_app_id IS NOT NULL)))),
CONSTRAINT workspace_builds_deadline_below_max_deadline CHECK ((((deadline <> '0001-01-01 00:00:00+00'::timestamp with time zone) AND (deadline <= max_deadline)) OR (max_deadline = '0001-01-01 00:00:00+00'::timestamp with time zone)))
);
CREATE VIEW workspace_build_with_user AS
+22
View File
@@ -59,6 +59,28 @@ func IsForeignKeyViolation(err error, foreignKeyConstraints ...ForeignKeyConstra
return false
}
// IsCheckViolation checks if the error is due to a check violation. If one or
// more specific check constraints are given as arguments, the error must be
// caused by one of them. If no constraints are given, this function returns
// true for any check violation.
func IsCheckViolation(err error, checkConstraints ...CheckConstraint) bool {
var pqErr *pq.Error
if errors.As(err, &pqErr) {
if pqErr.Code.Name() == "check_violation" {
if len(checkConstraints) == 0 {
return true
}
for _, cc := range checkConstraints {
if pqErr.Constraint == string(cc) {
return true
}
}
}
}
return false
}
// IsQueryCanceledError checks if the error is due to a query being canceled.
func IsQueryCanceledError(err error) bool {
var pqErr *pq.Error
@@ -0,0 +1,2 @@
ALTER TABLE workspace_builds
DROP CONSTRAINT workspace_builds_deadline_below_max_deadline;
@@ -0,0 +1,20 @@
-- New constraint: (deadline IS NOT zero AND deadline <= max_deadline) UNLESS max_deadline is zero.
-- Unfortunately, "zero" here means `time.Time{}`...
-- Update previous builds that would fail this new constraint. This matches the
-- intended behaviour of the autostop algorithm.
UPDATE
workspace_builds
SET
deadline = max_deadline
WHERE
deadline > max_deadline
AND max_deadline != '0001-01-01 00:00:00+00';
-- Add the new constraint.
ALTER TABLE workspace_builds
ADD CONSTRAINT workspace_builds_deadline_below_max_deadline
CHECK (
(deadline != '0001-01-01 00:00:00+00'::timestamptz AND deadline <= max_deadline)
OR max_deadline = '0001-01-01 00:00:00+00'::timestamptz
);
+99
View File
@@ -6003,3 +6003,102 @@ func TestGetRunningPrebuiltWorkspaces(t *testing.T) {
require.Len(t, runningPrebuilds, 1, "expected only one running prebuilt workspace")
require.Equal(t, runningPrebuild.ID, runningPrebuilds[0].ID, "expected the running prebuilt workspace to be returned")
}
func TestWorkspaceBuildDeadlineConstraint(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
db, _ := dbtestutil.NewDB(t)
org := dbgen.Organization(t, db, database.Organization{})
user := dbgen.User(t, db, database.User{})
template := dbgen.Template(t, db, database.Template{
CreatedBy: user.ID,
OrganizationID: org.ID,
})
templateVersion := dbgen.TemplateVersion(t, db, database.TemplateVersion{
TemplateID: uuid.NullUUID{UUID: template.ID, Valid: true},
OrganizationID: org.ID,
CreatedBy: user.ID,
})
workspace := dbgen.Workspace(t, db, database.WorkspaceTable{
OwnerID: user.ID,
TemplateID: template.ID,
Name: "test-workspace",
Deleted: false,
})
job := dbgen.ProvisionerJob(t, db, nil, database.ProvisionerJob{
OrganizationID: org.ID,
InitiatorID: database.PrebuildsSystemUserID,
Provisioner: database.ProvisionerTypeEcho,
Type: database.ProvisionerJobTypeWorkspaceBuild,
StartedAt: sql.NullTime{Time: time.Now().Add(-time.Minute), Valid: true},
CompletedAt: sql.NullTime{Time: time.Now(), Valid: true},
})
workspaceBuild := dbgen.WorkspaceBuild(t, db, database.WorkspaceBuild{
WorkspaceID: workspace.ID,
TemplateVersionID: templateVersion.ID,
JobID: job.ID,
BuildNumber: 1,
})
cases := []struct {
name string
deadline time.Time
maxDeadline time.Time
expectOK bool
}{
{
name: "no deadline or max_deadline",
deadline: time.Time{},
maxDeadline: time.Time{},
expectOK: true,
},
{
name: "deadline set when max_deadline is not set",
deadline: time.Now().Add(time.Hour),
maxDeadline: time.Time{},
expectOK: true,
},
{
name: "deadline before max_deadline",
deadline: time.Now().Add(-time.Hour),
maxDeadline: time.Now().Add(time.Hour),
expectOK: true,
},
{
name: "deadline is max_deadline",
deadline: time.Now().Add(time.Hour),
maxDeadline: time.Now().Add(time.Hour),
expectOK: true,
},
{
name: "deadline after max_deadline",
deadline: time.Now().Add(time.Hour),
maxDeadline: time.Now().Add(-time.Hour),
expectOK: false,
},
{
name: "deadline is not set when max_deadline is set",
deadline: time.Time{},
maxDeadline: time.Now().Add(time.Hour),
expectOK: false,
},
}
for _, c := range cases {
err := db.UpdateWorkspaceBuildDeadlineByID(ctx, database.UpdateWorkspaceBuildDeadlineByIDParams{
ID: workspaceBuild.ID,
Deadline: c.deadline,
MaxDeadline: c.maxDeadline,
UpdatedAt: time.Now(),
})
if c.expectOK {
require.NoError(t, err)
} else {
require.Error(t, err)
require.True(t, database.IsCheckViolation(err, database.CheckWorkspaceBuildsDeadlineBelowMaxDeadline))
}
}
}
@@ -1866,8 +1866,9 @@ func (s *server) completeWorkspaceBuildJob(ctx context.Context, job database.Pro
Database: db,
TemplateScheduleStore: templateScheduleStore,
UserQuietHoursScheduleStore: *s.UserQuietHoursScheduleStore.Load(),
Now: now,
Workspace: workspace.WorkspaceTable(),
// `now` is used below to set the build completion time.
WorkspaceBuildCompletedAt: now,
Workspace: workspace.WorkspaceTable(),
// Allowed to be the empty string.
WorkspaceAutostart: workspace.AutostartSchedule.String,
})
+71 -47
View File
@@ -50,8 +50,19 @@ type CalculateAutostopParams struct {
// by autobuild.NextAutostart
WorkspaceAutostart string
Now time.Time
Workspace database.WorkspaceTable
// WorkspaceBuildCompletedAt is the time when the workspace build was
// completed.
//
// We always want to calculate using the build completion time, and not just
// the current time, to avoid forcing a workspace build's max_deadline being
// pushed to the next potential cron instance.
//
// E.g. if this function is called for an existing workspace build, which
// currently has a max_deadline within the next 2 hours (see leeway
// above), and the current time is passed into this function, the
// max_deadline will be updated to be much later than expected.
WorkspaceBuildCompletedAt time.Time
Workspace database.WorkspaceTable
}
type AutostopTime struct {
@@ -68,8 +79,8 @@ type AutostopTime struct {
// Deadline is the time when the workspace will be stopped, as long as it
// doesn't see any new activity (such as SSH, app requests, etc.). When activity
// is detected the deadline is bumped by the workspace's TTL (this only happens
// when activity is detected and more than 20% of the TTL has passed to save
// database queries).
// when activity is detected and more than 5% of the TTL has passed to save
// database queries, see the ActivityBumpWorkspace query).
//
// MaxDeadline is the maximum value for deadline. The deadline cannot be bumped
// past this value, so it denotes the absolute deadline that the workspace build
@@ -77,55 +88,45 @@ type AutostopTime struct {
// requirement" settings and the user's "quiet hours" settings to pick a time
// outside of working hours.
//
// Deadline is a cost saving measure, while max deadline is a
// compliance/updating measure.
// Note that the deadline is checked at the database level:
//
// (deadline IS NOT zero AND deadline <= max_deadline) UNLESS max_deadline is zero.
//
// Deadline is intended as a cost saving measure, not as a hard policy. It is
// derived from either the workspace's TTL or the template's TTL, depending on
// the template's policy, to ensure workspaces are stopped when they are idle.
//
// MaxDeadline is intended as a compliance policy. It is derived from the
// template's autostop requirement to cap workspace uptime and effectively force
// people to update often.
//
// Note that only the build's CURRENT deadline property influences automation in
// the autobuild package. As stated above, the MaxDeadline property is only used
// to cap the value of a build's deadline.
func CalculateAutostop(ctx context.Context, params CalculateAutostopParams) (AutostopTime, error) {
ctx, span := tracing.StartSpan(ctx,
trace.WithAttributes(attribute.String("coder.workspace_id", params.Workspace.ID.String())),
trace.WithAttributes(attribute.String("coder.template_id", params.Workspace.TemplateID.String())),
)
defer span.End()
defer span.End()
var (
db = params.Database
workspace = params.Workspace
now = params.Now
db = params.Database
workspace = params.Workspace
buildCompletedAt = params.WorkspaceBuildCompletedAt
autostop AutostopTime
)
var ttl time.Duration
if workspace.Ttl.Valid {
// When the workspace is made it copies the template's TTL, and the user
// can unset it to disable it (unless the template has
// UserAutoStopEnabled set to false, see below).
ttl = time.Duration(workspace.Ttl.Int64)
}
if workspace.Ttl.Valid {
// When the workspace is made it copies the template's TTL, and the user
// can unset it to disable it (unless the template has
// UserAutoStopEnabled set to false, see below).
autostop.Deadline = now.Add(time.Duration(workspace.Ttl.Int64))
}
templateSchedule, err := params.TemplateScheduleStore.Get(ctx, db, workspace.TemplateID)
if err != nil {
return autostop, xerrors.Errorf("get template schedule options: %w", err)
}
if !templateSchedule.UserAutostopEnabled {
// The user is not permitted to set their own TTL, so use the template
// default.
ttl = 0
if templateSchedule.DefaultTTL > 0 {
ttl = templateSchedule.DefaultTTL
}
}
ttl := workspaceTTL(workspace, templateSchedule)
if ttl > 0 {
// Only apply non-zero TTLs.
autostop.Deadline = now.Add(ttl)
autostop.Deadline = buildCompletedAt.Add(ttl)
if params.WorkspaceAutostart != "" {
// If the deadline passes the next autostart, we need to extend the deadline to
// autostart + deadline. ActivityBumpWorkspace already covers this case
@@ -137,14 +138,14 @@ func CalculateAutostop(ctx context.Context, params CalculateAutostopParams) (Aut
// 3. User starts workspace at 9:45pm.
// - The initial deadline is calculated to be 9:45am
// - This crosses the autostart deadline, so the deadline is extended to 9pm
nextAutostart, ok := NextAutostart(params.Now, params.WorkspaceAutostart, templateSchedule)
nextAutostart, ok := NextAutostart(params.WorkspaceBuildCompletedAt, params.WorkspaceAutostart, templateSchedule)
if ok && autostop.Deadline.After(nextAutostart) {
autostop.Deadline = nextAutostart.Add(ttl)
}
}
}
// Otherwise, use the autostop_requirement algorithm.
// Enforce the template autostop requirement if it's configured correctly.
if templateSchedule.AutostopRequirement.DaysOfWeek != 0 {
// The template has a autostop requirement, so determine the max deadline
// of this workspace build.
@@ -161,10 +162,10 @@ func CalculateAutostop(ctx context.Context, params CalculateAutostopParams) (Aut
// workspace.
if userQuietHoursSchedule.Schedule != nil {
loc := userQuietHoursSchedule.Schedule.Location()
now := now.In(loc)
buildCompletedAtInLoc := buildCompletedAt.In(loc)
// Add the leeway here so we avoid checking today's quiet hours if
// the workspace was started <1h before midnight.
startOfStopDay := truncateMidnight(now.Add(autostopRequirementLeeway))
startOfStopDay := truncateMidnight(buildCompletedAtInLoc.Add(autostopRequirementLeeway))
// If the template schedule wants to only autostop on n-th weeks
// then change the startOfDay to be the Monday of the next
@@ -183,7 +184,7 @@ func CalculateAutostop(ctx context.Context, params CalculateAutostopParams) (Aut
// hour of the scheduled stop time will always bounce to the next
// stop window).
checkSchedule := userQuietHoursSchedule.Schedule.Next(startOfStopDay.Add(autostopRequirementBuffer))
if checkSchedule.Before(now.Add(autostopRequirementLeeway)) {
if checkSchedule.Before(buildCompletedAtInLoc.Add(autostopRequirementLeeway)) {
// Set the first stop day we try to tomorrow because today's
// schedule is too close to now or has already passed.
startOfStopDay = nextDayMidnight(startOfStopDay)
@@ -213,14 +214,17 @@ func CalculateAutostop(ctx context.Context, params CalculateAutostopParams) (Aut
startOfStopDay = nextDayMidnight(startOfStopDay)
}
// If the startOfDay is within an hour of now, then we add an hour.
// If the startOfDay is within an hour of the build completion time,
// then we add an hour.
checkTime := startOfStopDay
if checkTime.Before(now.Add(time.Hour)) {
checkTime = now.Add(time.Hour)
if checkTime.Before(buildCompletedAtInLoc.Add(time.Hour)) {
checkTime = buildCompletedAtInLoc.Add(time.Hour)
} else {
// If it's not within an hour of now, subtract 15 minutes to
// give a little leeway. This prevents skipped stop events
// because autostart perfectly lines up with autostop.
// If it's not within an hour of the build completion time,
// subtract 15 minutes to give a little leeway. This prevents
// skipped stop events because the build time (e.g. autostart
// time) perfectly lines up with the max_deadline minus the
// leeway.
checkTime = checkTime.Add(autostopRequirementBuffer)
}
@@ -238,15 +242,35 @@ func CalculateAutostop(ctx context.Context, params CalculateAutostopParams) (Aut
autostop.Deadline = autostop.MaxDeadline
}
if (!autostop.Deadline.IsZero() && autostop.Deadline.Before(now)) || (!autostop.MaxDeadline.IsZero() && autostop.MaxDeadline.Before(now)) {
if (!autostop.Deadline.IsZero() && autostop.Deadline.Before(buildCompletedAt)) || (!autostop.MaxDeadline.IsZero() && autostop.MaxDeadline.Before(buildCompletedAt)) {
// Something went wrong with the deadline calculation, so we should
// bail.
return autostop, xerrors.Errorf("deadline calculation error, computed deadline or max deadline is in the past for workspace build: deadline=%q maxDeadline=%q now=%q", autostop.Deadline, autostop.MaxDeadline, now)
return autostop, xerrors.Errorf("deadline calculation error, computed deadline or max deadline is in the past for workspace build: deadline=%q maxDeadline=%q now=%q", autostop.Deadline, autostop.MaxDeadline, buildCompletedAt)
}
return autostop, nil
}
// workspaceTTL returns the TTL to use for a workspace.
//
// If the template forbids custom workspace TTLs, then we always use the
// template's configured TTL (or 0 if the template has no TTL configured).
func workspaceTTL(workspace database.WorkspaceTable, templateSchedule TemplateScheduleOptions) time.Duration {
// If the template forbids custom workspace TTLs, then we always use the
// template's configured TTL (or 0 if the template has no TTL configured).
if !templateSchedule.UserAutostopEnabled {
// This is intentionally a nested if statement because of the else if.
if templateSchedule.DefaultTTL > 0 {
return templateSchedule.DefaultTTL
}
return 0
}
if workspace.Ttl.Valid {
return time.Duration(workspace.Ttl.Int64)
}
return 0
}
// truncateMidnight truncates a time to midnight in the time object's timezone.
// t.Truncate(24 * time.Hour) truncates based on the internal time and doesn't
// factor daylight savings properly.
+28 -28
View File
@@ -76,8 +76,8 @@ func TestCalculateAutoStop(t *testing.T) {
t.Log("saturdayMidnightAfterDstOut", saturdayMidnightAfterDstOut)
cases := []struct {
name string
now time.Time
name string
buildCompletedAt time.Time
wsAutostart string
templateAutoStart schedule.TemplateAutostartRequirement
@@ -98,7 +98,7 @@ func TestCalculateAutoStop(t *testing.T) {
}{
{
name: "OK",
now: now,
buildCompletedAt: now,
templateAllowAutostop: true,
templateDefaultTTL: 0,
templateAutostopRequirement: schedule.TemplateAutostopRequirement{},
@@ -108,7 +108,7 @@ func TestCalculateAutoStop(t *testing.T) {
},
{
name: "Delete",
now: now,
buildCompletedAt: now,
templateAllowAutostop: true,
templateDefaultTTL: 0,
templateAutostopRequirement: schedule.TemplateAutostopRequirement{},
@@ -118,7 +118,7 @@ func TestCalculateAutoStop(t *testing.T) {
},
{
name: "WorkspaceTTL",
now: now,
buildCompletedAt: now,
templateAllowAutostop: true,
templateDefaultTTL: 0,
templateAutostopRequirement: schedule.TemplateAutostopRequirement{},
@@ -128,7 +128,7 @@ func TestCalculateAutoStop(t *testing.T) {
},
{
name: "TemplateDefaultTTLIgnored",
now: now,
buildCompletedAt: now,
templateAllowAutostop: true,
templateDefaultTTL: time.Hour,
templateAutostopRequirement: schedule.TemplateAutostopRequirement{},
@@ -138,7 +138,7 @@ func TestCalculateAutoStop(t *testing.T) {
},
{
name: "WorkspaceTTLOverridesTemplateDefaultTTL",
now: now,
buildCompletedAt: now,
templateAllowAutostop: true,
templateDefaultTTL: 2 * time.Hour,
templateAutostopRequirement: schedule.TemplateAutostopRequirement{},
@@ -148,7 +148,7 @@ func TestCalculateAutoStop(t *testing.T) {
},
{
name: "TemplateBlockWorkspaceTTL",
now: now,
buildCompletedAt: now,
templateAllowAutostop: false,
templateDefaultTTL: 3 * time.Hour,
templateAutostopRequirement: schedule.TemplateAutostopRequirement{},
@@ -158,7 +158,7 @@ func TestCalculateAutoStop(t *testing.T) {
},
{
name: "TemplateAutostopRequirement",
now: wednesdayMidnightUTC,
buildCompletedAt: wednesdayMidnightUTC,
templateAllowAutostop: true,
templateDefaultTTL: 0,
userQuietHoursSchedule: sydneyQuietHours,
@@ -172,7 +172,7 @@ func TestCalculateAutoStop(t *testing.T) {
},
{
name: "TemplateAutostopRequirement1HourSkip",
now: saturdayMidnightSydney.Add(-59 * time.Minute),
buildCompletedAt: saturdayMidnightSydney.Add(-59 * time.Minute),
templateAllowAutostop: true,
templateDefaultTTL: 0,
userQuietHoursSchedule: sydneyQuietHours,
@@ -188,7 +188,7 @@ func TestCalculateAutoStop(t *testing.T) {
// The next autostop requirement should be skipped if the
// workspace is started within 1 hour of it.
name: "TemplateAutostopRequirementDaily",
now: fridayEveningSydney,
buildCompletedAt: fridayEveningSydney,
templateAllowAutostop: true,
templateDefaultTTL: 0,
userQuietHoursSchedule: sydneyQuietHours,
@@ -202,7 +202,7 @@ func TestCalculateAutoStop(t *testing.T) {
},
{
name: "TemplateAutostopRequirementFortnightly/Skip",
now: wednesdayMidnightUTC,
buildCompletedAt: wednesdayMidnightUTC,
templateAllowAutostop: true,
templateDefaultTTL: 0,
userQuietHoursSchedule: sydneyQuietHours,
@@ -216,7 +216,7 @@ func TestCalculateAutoStop(t *testing.T) {
},
{
name: "TemplateAutostopRequirementFortnightly/NoSkip",
now: wednesdayMidnightUTC.AddDate(0, 0, 7),
buildCompletedAt: wednesdayMidnightUTC.AddDate(0, 0, 7),
templateAllowAutostop: true,
templateDefaultTTL: 0,
userQuietHoursSchedule: sydneyQuietHours,
@@ -230,7 +230,7 @@ func TestCalculateAutoStop(t *testing.T) {
},
{
name: "TemplateAutostopRequirementTriweekly/Skip",
now: wednesdayMidnightUTC,
buildCompletedAt: wednesdayMidnightUTC,
templateAllowAutostop: true,
templateDefaultTTL: 0,
userQuietHoursSchedule: sydneyQuietHours,
@@ -246,7 +246,7 @@ func TestCalculateAutoStop(t *testing.T) {
},
{
name: "TemplateAutostopRequirementTriweekly/NoSkip",
now: wednesdayMidnightUTC.AddDate(0, 0, 7),
buildCompletedAt: wednesdayMidnightUTC.AddDate(0, 0, 7),
templateAllowAutostop: true,
templateDefaultTTL: 0,
userQuietHoursSchedule: sydneyQuietHours,
@@ -262,7 +262,7 @@ func TestCalculateAutoStop(t *testing.T) {
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),
buildCompletedAt: fridayEveningSydney.In(time.UTC),
templateAllowAutostop: true,
templateDefaultTTL: 0,
userQuietHoursSchedule: sydneyQuietHours,
@@ -276,7 +276,7 @@ func TestCalculateAutoStop(t *testing.T) {
},
{
name: "TemplateAutostopRequirementOverridesTemplateDefaultTTL",
now: fridayEveningSydney.In(time.UTC),
buildCompletedAt: fridayEveningSydney.In(time.UTC),
templateAllowAutostop: true,
templateDefaultTTL: 3 * time.Hour,
userQuietHoursSchedule: sydneyQuietHours,
@@ -293,7 +293,7 @@ func TestCalculateAutoStop(t *testing.T) {
// 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 2h leeway logic.
now: time.Date(2023, 1, 1, 21, 59, 59, 0, sydneyLoc),
buildCompletedAt: time.Date(2023, 1, 1, 21, 59, 59, 0, sydneyLoc),
templateAllowAutostop: true,
templateDefaultTTL: 0,
userQuietHoursSchedule: sydneyQuietHours,
@@ -306,7 +306,7 @@ func TestCalculateAutoStop(t *testing.T) {
},
{
name: "DaylightSavings/OK",
now: duringDst,
buildCompletedAt: duringDst,
templateAllowAutostop: true,
templateDefaultTTL: 0,
userQuietHoursSchedule: sydneyQuietHours,
@@ -320,7 +320,7 @@ func TestCalculateAutoStop(t *testing.T) {
},
{
name: "DaylightSavings/SwitchMidWeek/In",
now: beforeDstIn,
buildCompletedAt: beforeDstIn,
templateAllowAutostop: true,
templateDefaultTTL: 0,
userQuietHoursSchedule: sydneyQuietHours,
@@ -334,7 +334,7 @@ func TestCalculateAutoStop(t *testing.T) {
},
{
name: "DaylightSavings/SwitchMidWeek/Out",
now: beforeDstOut,
buildCompletedAt: beforeDstOut,
templateAllowAutostop: true,
templateDefaultTTL: 0,
userQuietHoursSchedule: sydneyQuietHours,
@@ -348,7 +348,7 @@ func TestCalculateAutoStop(t *testing.T) {
},
{
name: "DaylightSavings/QuietHoursFallsOnDstSwitch/In",
now: beforeDstIn.Add(-24 * time.Hour),
buildCompletedAt: beforeDstIn.Add(-24 * time.Hour),
templateAllowAutostop: true,
templateDefaultTTL: 0,
userQuietHoursSchedule: dstInQuietHours,
@@ -362,7 +362,7 @@ func TestCalculateAutoStop(t *testing.T) {
},
{
name: "DaylightSavings/QuietHoursFallsOnDstSwitch/Out",
now: beforeDstOut.Add(-24 * time.Hour),
buildCompletedAt: beforeDstOut.Add(-24 * time.Hour),
templateAllowAutostop: true,
templateDefaultTTL: 0,
userQuietHoursSchedule: dstOutQuietHours,
@@ -382,7 +382,7 @@ func TestCalculateAutoStop(t *testing.T) {
// activity on the workspace.
name: "AutostopCrossAutostartBorder",
// Starting at 9:45pm, with the autostart at 9am.
now: pastDateNight,
buildCompletedAt: pastDateNight,
templateAllowAutostop: false,
templateDefaultTTL: time.Hour * 12,
workspaceTTL: time.Hour * 12,
@@ -405,7 +405,7 @@ func TestCalculateAutoStop(t *testing.T) {
// Same as AutostopCrossAutostartBorder, but just misses the autostart.
name: "AutostopCrossMissAutostartBorder",
// Starting at 8:45pm, with the autostart at 9am.
now: time.Date(pastDateNight.Year(), pastDateNight.Month(), pastDateNight.Day(), 20, 30, 0, 0, chicago),
buildCompletedAt: time.Date(pastDateNight.Year(), pastDateNight.Month(), pastDateNight.Day(), 20, 30, 0, 0, chicago),
templateAllowAutostop: false,
templateDefaultTTL: time.Hour * 12,
workspaceTTL: time.Hour * 12,
@@ -429,7 +429,7 @@ func TestCalculateAutoStop(t *testing.T) {
// The autostop deadline is before the autostart threshold.
name: "AutostopCrossAutostartBorderMaxEarlyDeadline",
// Starting at 9:45pm, with the autostart at 9am.
now: pastDateNight,
buildCompletedAt: pastDateNight,
templateAllowAutostop: false,
templateDefaultTTL: time.Hour * 12,
workspaceTTL: time.Hour * 12,
@@ -459,7 +459,7 @@ func TestCalculateAutoStop(t *testing.T) {
// So the deadline is > 12 hours, but stops at the max deadline.
name: "AutostopCrossAutostartBorderMaxDeadline",
// Starting at 9:45pm, with the autostart at 9am.
now: pastDateNight,
buildCompletedAt: pastDateNight,
templateAllowAutostop: false,
templateDefaultTTL: time.Hour * 12,
workspaceTTL: time.Hour * 12,
@@ -571,7 +571,7 @@ func TestCalculateAutoStop(t *testing.T) {
Database: db,
TemplateScheduleStore: templateScheduleStore,
UserQuietHoursScheduleStore: userQuietHoursScheduleStore,
Now: c.now,
WorkspaceBuildCompletedAt: c.buildCompletedAt,
Workspace: workspace,
WorkspaceAutostart: c.wsAutostart,
})
+20 -6
View File
@@ -45,8 +45,8 @@ import (
)
var (
ttlMin = time.Minute //nolint:revive // min here means 'minimum' not 'minutes'
ttlMax = 30 * 24 * time.Hour
ttlMinimum = time.Minute
ttlMaximum = 30 * 24 * time.Hour
errTTLMin = xerrors.New("time until shutdown must be at least one minute")
errTTLMax = xerrors.New("time until shutdown must be less than 30 days")
@@ -1190,8 +1190,22 @@ func (api *API) putWorkspaceTTL(rw http.ResponseWriter, r *http.Request) {
if build.Transition == database.WorkspaceTransitionStart {
if err = s.UpdateWorkspaceBuildDeadlineByID(ctx, database.UpdateWorkspaceBuildDeadlineByIDParams{
ID: build.ID,
Deadline: time.Time{},
ID: build.ID,
// Use the max_deadline as the new build deadline. It will
// either be zero (our target), or a non-zero value that we
// need to abide by anyway due to template policy.
//
// Previously, we would always set the deadline to zero,
// which was incorrect behavior. When max_deadline is
// non-zero, deadline must be set to a non-zero value that
// is less than max_deadline.
//
// Disabling TTL autostop (at a workspace or template level)
// does not trump the template's autostop requirement.
//
// Refer to the comments on schedule.CalculateAutostop for
// more information.
Deadline: build.MaxDeadline,
MaxDeadline: build.MaxDeadline,
UpdatedAt: dbtime.Time(api.Clock.Now()),
}); err != nil {
@@ -2391,11 +2405,11 @@ func validWorkspaceTTLMillis(millis *int64, templateDefault time.Duration) (sql.
dur := time.Duration(*millis) * time.Millisecond
truncated := dur.Truncate(time.Minute)
if truncated < ttlMin {
if truncated < ttlMinimum {
return sql.NullInt64{}, errTTLMin
}
if truncated > ttlMax {
if truncated > ttlMaximum {
return sql.NullInt64{}, errTTLMax
}
+49
View File
@@ -2897,6 +2897,55 @@ func TestWorkspaceUpdateTTL(t *testing.T) {
}
})
t.Run("RemoveAutostopWithRunningWorkspaceWithMaxDeadline", func(t *testing.T) {
t.Parallel()
var (
ctx = testutil.Context(t, testutil.WaitLong)
client, db = coderdtest.NewWithDatabase(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
user = coderdtest.CreateFirstUser(t, client)
version = coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
_ = coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
template = coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
deadline = 8 * time.Hour
maxDeadline = 10 * time.Hour
workspace = coderdtest.CreateWorkspace(t, client, template.ID, func(cwr *codersdk.CreateWorkspaceRequest) {
cwr.TTLMillis = ptr.Ref(deadline.Milliseconds())
})
build = coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID)
)
// This is a hack, but the max_deadline isn't precisely configurable
// without a lot of unnecessary hassle.
dbBuild, err := db.GetWorkspaceBuildByID(dbauthz.AsSystemRestricted(ctx), build.ID) //nolint:gocritic // test
require.NoError(t, err)
dbJob, err := db.GetProvisionerJobByID(dbauthz.AsSystemRestricted(ctx), dbBuild.JobID) //nolint:gocritic // test
require.NoError(t, err)
require.True(t, dbJob.CompletedAt.Valid)
expectedMaxDeadline := dbJob.CompletedAt.Time.Add(maxDeadline)
err = db.UpdateWorkspaceBuildDeadlineByID(dbauthz.AsSystemRestricted(ctx), database.UpdateWorkspaceBuildDeadlineByIDParams{ //nolint:gocritic // test
ID: build.ID,
Deadline: dbBuild.Deadline,
MaxDeadline: expectedMaxDeadline,
UpdatedAt: dbtime.Now(),
})
require.NoError(t, err)
// Remove autostop.
err = client.UpdateWorkspaceTTL(ctx, workspace.ID, codersdk.UpdateWorkspaceTTLRequest{
TTLMillis: nil,
})
require.NoError(t, err)
// Expect that the deadline is set to the max_deadline.
build, err = client.WorkspaceBuild(ctx, build.ID)
require.NoError(t, err)
require.True(t, build.Deadline.Valid)
require.WithinDuration(t, build.Deadline.Time, expectedMaxDeadline, time.Second)
require.True(t, build.MaxDeadline.Valid)
require.WithinDuration(t, build.MaxDeadline.Time, expectedMaxDeadline, time.Second)
})
t.Run("CustomAutostopDisabledByTemplate", func(t *testing.T) {
t.Parallel()
var (