mirror of
https://github.com/coder/coder.git
synced 2025-08-01 08:28:48 +00:00
feat: update workspace deadline when template policy changes (#8964)
This commit is contained in:
@@ -6,10 +6,16 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/coderd/database"
|
||||
"github.com/coder/coder/coderd/database/db2sdk"
|
||||
"github.com/coder/coder/coderd/database/dbauthz"
|
||||
agpl "github.com/coder/coder/coderd/schedule"
|
||||
"github.com/coder/coder/coderd/tracing"
|
||||
"github.com/coder/coder/codersdk"
|
||||
)
|
||||
|
||||
// EnterpriseTemplateScheduleStore provides an agpl.TemplateScheduleStore that
|
||||
@@ -20,16 +26,35 @@ type EnterpriseTemplateScheduleStore struct {
|
||||
// workspace build. This value is determined by a feature flag, licensing,
|
||||
// and whether a default user quiet hours schedule is set.
|
||||
UseRestartRequirement atomic.Bool
|
||||
|
||||
// UserQuietHoursScheduleStore is used when recalculating build deadlines on
|
||||
// update.
|
||||
UserQuietHoursScheduleStore *atomic.Pointer[agpl.UserQuietHoursScheduleStore]
|
||||
|
||||
// Custom time.Now() function to use in tests. Defaults to database.Now().
|
||||
TimeNowFn func() time.Time
|
||||
}
|
||||
|
||||
var _ agpl.TemplateScheduleStore = &EnterpriseTemplateScheduleStore{}
|
||||
|
||||
func NewEnterpriseTemplateScheduleStore() *EnterpriseTemplateScheduleStore {
|
||||
return &EnterpriseTemplateScheduleStore{}
|
||||
func NewEnterpriseTemplateScheduleStore(userQuietHoursStore *atomic.Pointer[agpl.UserQuietHoursScheduleStore]) *EnterpriseTemplateScheduleStore {
|
||||
return &EnterpriseTemplateScheduleStore{
|
||||
UserQuietHoursScheduleStore: userQuietHoursStore,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *EnterpriseTemplateScheduleStore) now() time.Time {
|
||||
if s.TimeNowFn != nil {
|
||||
return s.TimeNowFn()
|
||||
}
|
||||
return database.Now()
|
||||
}
|
||||
|
||||
// Get implements agpl.TemplateScheduleStore.
|
||||
func (s *EnterpriseTemplateScheduleStore) Get(ctx context.Context, db database.Store, templateID uuid.UUID) (agpl.TemplateScheduleOptions, error) {
|
||||
ctx, span := tracing.StartSpan(ctx)
|
||||
defer span.End()
|
||||
|
||||
tpl, err := db.GetTemplateByID(ctx, templateID)
|
||||
if err != nil {
|
||||
return agpl.TemplateScheduleOptions{}, err
|
||||
@@ -65,7 +90,10 @@ func (s *EnterpriseTemplateScheduleStore) Get(ctx context.Context, db database.S
|
||||
}
|
||||
|
||||
// Set implements agpl.TemplateScheduleStore.
|
||||
func (*EnterpriseTemplateScheduleStore) Set(ctx context.Context, db database.Store, tpl database.Template, opts agpl.TemplateScheduleOptions) (database.Template, error) {
|
||||
func (s *EnterpriseTemplateScheduleStore) Set(ctx context.Context, db database.Store, tpl database.Template, opts agpl.TemplateScheduleOptions) (database.Template, error) {
|
||||
ctx, span := tracing.StartSpan(ctx)
|
||||
defer span.End()
|
||||
|
||||
if int64(opts.DefaultTTL) == tpl.DefaultTTL &&
|
||||
int64(opts.MaxTTL) == tpl.MaxTTL &&
|
||||
int16(opts.RestartRequirement.DaysOfWeek) == tpl.RestartRequirementDaysOfWeek &&
|
||||
@@ -86,9 +114,12 @@ func (*EnterpriseTemplateScheduleStore) Set(ctx context.Context, db database.Sto
|
||||
|
||||
var template database.Template
|
||||
err = db.InTx(func(db database.Store) error {
|
||||
ctx, span := tracing.StartSpanWithName(ctx, "(*schedule.EnterpriseTemplateScheduleStore).Set()-InTx()")
|
||||
defer span.End()
|
||||
|
||||
err := db.UpdateTemplateScheduleByID(ctx, database.UpdateTemplateScheduleByIDParams{
|
||||
ID: tpl.ID,
|
||||
UpdatedAt: database.Now(),
|
||||
UpdatedAt: s.now(),
|
||||
AllowUserAutostart: opts.UserAutostartEnabled,
|
||||
AllowUserAutostop: opts.UserAutostopEnabled,
|
||||
DefaultTTL: int64(opts.DefaultTTL),
|
||||
@@ -115,12 +146,20 @@ func (*EnterpriseTemplateScheduleStore) Set(ctx context.Context, db database.Sto
|
||||
return xerrors.Errorf("update deleting_at of all workspaces for new locked_ttl %q: %w", opts.LockedTTL, err)
|
||||
}
|
||||
|
||||
// TODO: update all workspace max_deadlines to be within new bounds
|
||||
template, err = db.GetTemplateByID(ctx, tpl.ID)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("get updated template schedule: %w", err)
|
||||
}
|
||||
|
||||
// Recalculate max_deadline and deadline for all running workspace
|
||||
// builds on this template.
|
||||
if s.UseRestartRequirement.Load() {
|
||||
err = s.updateWorkspaceBuilds(ctx, db, template)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("update workspace builds: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}, nil)
|
||||
if err != nil {
|
||||
@@ -129,3 +168,98 @@ func (*EnterpriseTemplateScheduleStore) Set(ctx context.Context, db database.Sto
|
||||
|
||||
return template, nil
|
||||
}
|
||||
|
||||
func (s *EnterpriseTemplateScheduleStore) updateWorkspaceBuilds(ctx context.Context, db database.Store, template database.Template) error {
|
||||
ctx, span := tracing.StartSpan(ctx)
|
||||
defer span.End()
|
||||
|
||||
//nolint:gocritic // This function will retrieve all workspace builds on
|
||||
// the template and update their max deadline to be within the new
|
||||
// policy parameters.
|
||||
ctx = dbauthz.AsSystemRestricted(ctx)
|
||||
|
||||
builds, err := db.GetActiveWorkspaceBuildsByTemplateID(ctx, template.ID)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("get active workspace builds: %w", err)
|
||||
}
|
||||
|
||||
for _, build := range builds {
|
||||
err := s.updateWorkspaceBuild(ctx, db, build)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("update workspace build %q: %w", build.ID, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *EnterpriseTemplateScheduleStore) updateWorkspaceBuild(ctx context.Context, db database.Store, build database.WorkspaceBuild) error {
|
||||
ctx, span := tracing.StartSpan(ctx,
|
||||
trace.WithAttributes(attribute.String("coder.workspace_id", build.WorkspaceID.String())),
|
||||
trace.WithAttributes(attribute.String("coder.workspace_build_id", build.ID.String())),
|
||||
)
|
||||
defer span.End()
|
||||
|
||||
if !build.MaxDeadline.IsZero() && build.MaxDeadline.Before(s.now().Add(2*time.Hour)) {
|
||||
// Skip this since it's already too close to the max_deadline.
|
||||
return nil
|
||||
}
|
||||
|
||||
workspace, err := db.GetWorkspaceByID(ctx, build.WorkspaceID)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("get workspace %q: %w", build.WorkspaceID, err)
|
||||
}
|
||||
|
||||
job, err := db.GetProvisionerJobByID(ctx, build.JobID)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("get provisioner job %q: %w", build.JobID, err)
|
||||
}
|
||||
if db2sdk.ProvisionerJobStatus(job) != codersdk.ProvisionerJobSucceeded {
|
||||
// Only touch builds that are completed.
|
||||
return nil
|
||||
}
|
||||
|
||||
// If the job completed before the autostop epoch, then it must be skipped
|
||||
// to avoid failures below. Add a week to account for timezones.
|
||||
if job.CompletedAt.Time.Before(agpl.TemplateRestartRequirementEpoch(time.UTC).Add(time.Hour * 7 * 24)) {
|
||||
return nil
|
||||
}
|
||||
|
||||
autostop, err := agpl.CalculateAutostop(ctx, agpl.CalculateAutostopParams{
|
||||
Database: db,
|
||||
TemplateScheduleStore: s,
|
||||
UserQuietHoursScheduleStore: *s.UserQuietHoursScheduleStore.Load(),
|
||||
// Use the job completion time as the time we calculate autostop from.
|
||||
Now: job.CompletedAt.Time,
|
||||
Workspace: workspace,
|
||||
})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("calculate new autostop for workspace %q: %w", workspace.ID, err)
|
||||
}
|
||||
|
||||
// If max deadline is before now()+2h, then set it to that.
|
||||
now := s.now()
|
||||
if autostop.MaxDeadline.Before(now.Add(2 * time.Hour)) {
|
||||
autostop.MaxDeadline = now.Add(time.Hour * 2)
|
||||
}
|
||||
|
||||
// If the current deadline on the build is after the new max_deadline, then
|
||||
// set it to the max_deadline.
|
||||
autostop.Deadline = build.Deadline
|
||||
if autostop.Deadline.After(autostop.MaxDeadline) {
|
||||
autostop.Deadline = autostop.MaxDeadline
|
||||
}
|
||||
|
||||
// Update the workspace build.
|
||||
err = db.UpdateWorkspaceBuildByID(ctx, database.UpdateWorkspaceBuildByIDParams{
|
||||
ID: build.ID,
|
||||
UpdatedAt: now,
|
||||
Deadline: autostop.Deadline,
|
||||
MaxDeadline: autostop.MaxDeadline,
|
||||
})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("update workspace build %q: %w", build.ID, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
523
enterprise/coderd/schedule/template_test.go
Normal file
523
enterprise/coderd/schedule/template_test.go
Normal file
@@ -0,0 +1,523 @@
|
||||
package schedule_test
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/coderd/database"
|
||||
"github.com/coder/coder/coderd/database/dbgen"
|
||||
"github.com/coder/coder/coderd/database/dbtestutil"
|
||||
agplschedule "github.com/coder/coder/coderd/schedule"
|
||||
"github.com/coder/coder/enterprise/coderd/schedule"
|
||||
"github.com/coder/coder/testutil"
|
||||
)
|
||||
|
||||
func TestTemplateUpdateBuildDeadlines(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db, _ := dbtestutil.NewDB(t)
|
||||
|
||||
var (
|
||||
org = dbgen.Organization(t, db, database.Organization{})
|
||||
user = dbgen.User(t, db, database.User{})
|
||||
file = dbgen.File(t, db, database.File{
|
||||
CreatedBy: user.ID,
|
||||
})
|
||||
templateJob = dbgen.ProvisionerJob(t, db, database.ProvisionerJob{
|
||||
OrganizationID: org.ID,
|
||||
FileID: file.ID,
|
||||
InitiatorID: user.ID,
|
||||
Tags: database.StringMap{
|
||||
"foo": "bar",
|
||||
},
|
||||
})
|
||||
templateVersion = dbgen.TemplateVersion(t, db, database.TemplateVersion{
|
||||
OrganizationID: org.ID,
|
||||
CreatedBy: user.ID,
|
||||
JobID: templateJob.ID,
|
||||
})
|
||||
)
|
||||
|
||||
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
|
||||
nextQuietHours := time.Date(nowY, nowM, nowD+1, 0, 0, 0, 0, time.UTC) // midnight tomorrow UTC
|
||||
|
||||
// Workspace old max_deadline too soon
|
||||
cases := []struct {
|
||||
name string
|
||||
now time.Time
|
||||
deadline time.Time
|
||||
maxDeadline time.Time
|
||||
newDeadline time.Time // 0 for no change
|
||||
newMaxDeadline time.Time
|
||||
}{
|
||||
{
|
||||
name: "SkippedWorkspaceMaxDeadlineTooSoon",
|
||||
now: buildTime,
|
||||
deadline: buildTime,
|
||||
maxDeadline: buildTime.Add(1 * time.Hour),
|
||||
// Unchanged since the max deadline is too soon.
|
||||
newDeadline: time.Time{},
|
||||
newMaxDeadline: buildTime.Add(1 * time.Hour),
|
||||
},
|
||||
{
|
||||
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: time.Time{},
|
||||
// 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),
|
||||
},
|
||||
{
|
||||
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: time.Time{},
|
||||
// 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),
|
||||
},
|
||||
{
|
||||
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: time.Time{},
|
||||
newMaxDeadline: nextQuietHours,
|
||||
},
|
||||
{
|
||||
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,
|
||||
},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
c := c
|
||||
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
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)
|
||||
|
||||
var (
|
||||
template = dbgen.Template(t, db, database.Template{
|
||||
OrganizationID: org.ID,
|
||||
ActiveVersionID: templateVersion.ID,
|
||||
CreatedBy: user.ID,
|
||||
})
|
||||
ws = dbgen.Workspace(t, db, database.Workspace{
|
||||
OrganizationID: org.ID,
|
||||
OwnerID: user.ID,
|
||||
TemplateID: template.ID,
|
||||
})
|
||||
job = dbgen.ProvisionerJob(t, db, database.ProvisionerJob{
|
||||
OrganizationID: org.ID,
|
||||
FileID: file.ID,
|
||||
InitiatorID: user.ID,
|
||||
Provisioner: database.ProvisionerTypeEcho,
|
||||
Tags: database.StringMap{
|
||||
c.name: "yeah",
|
||||
},
|
||||
})
|
||||
wsBuild = dbgen.WorkspaceBuild(t, db, database.WorkspaceBuild{
|
||||
WorkspaceID: ws.ID,
|
||||
BuildNumber: 1,
|
||||
JobID: job.ID,
|
||||
InitiatorID: user.ID,
|
||||
TemplateVersionID: templateVersion.ID,
|
||||
})
|
||||
)
|
||||
|
||||
acquiredJob, err := db.AcquireProvisionerJob(ctx, database.AcquireProvisionerJobParams{
|
||||
StartedAt: sql.NullTime{
|
||||
Time: buildTime,
|
||||
Valid: true,
|
||||
},
|
||||
WorkerID: uuid.NullUUID{
|
||||
UUID: uuid.New(),
|
||||
Valid: true,
|
||||
},
|
||||
Types: []database.ProvisionerType{database.ProvisionerTypeEcho},
|
||||
Tags: json.RawMessage(fmt.Sprintf(`{%q: "yeah"}`, c.name)),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, job.ID, acquiredJob.ID)
|
||||
err = db.UpdateProvisionerJobWithCompleteByID(ctx, database.UpdateProvisionerJobWithCompleteByIDParams{
|
||||
ID: job.ID,
|
||||
CompletedAt: sql.NullTime{
|
||||
Time: buildTime,
|
||||
Valid: true,
|
||||
},
|
||||
UpdatedAt: buildTime,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
err = db.UpdateWorkspaceBuildByID(ctx, database.UpdateWorkspaceBuildByIDParams{
|
||||
ID: wsBuild.ID,
|
||||
UpdatedAt: buildTime,
|
||||
ProvisionerState: []byte{},
|
||||
Deadline: c.deadline,
|
||||
MaxDeadline: c.maxDeadline,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
wsBuild, err = db.GetWorkspaceBuildByID(ctx, wsBuild.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
userQuietHoursStore, err := schedule.NewEnterpriseUserQuietHoursScheduleStore(userQuietHoursSchedule)
|
||||
require.NoError(t, err)
|
||||
userQuietHoursStorePtr := &atomic.Pointer[agplschedule.UserQuietHoursScheduleStore]{}
|
||||
userQuietHoursStorePtr.Store(&userQuietHoursStore)
|
||||
|
||||
// Set the template policy.
|
||||
templateScheduleStore := schedule.NewEnterpriseTemplateScheduleStore(userQuietHoursStorePtr)
|
||||
templateScheduleStore.UseRestartRequirement.Store(true)
|
||||
templateScheduleStore.TimeNowFn = func() time.Time {
|
||||
return c.now
|
||||
}
|
||||
_, err = templateScheduleStore.Set(ctx, db, template, agplschedule.TemplateScheduleOptions{
|
||||
UserAutostartEnabled: false,
|
||||
UserAutostopEnabled: false,
|
||||
DefaultTTL: 0,
|
||||
MaxTTL: 0,
|
||||
UseRestartRequirement: true,
|
||||
RestartRequirement: agplschedule.TemplateRestartRequirement{
|
||||
// Every day
|
||||
DaysOfWeek: 0b01111111,
|
||||
Weeks: 0,
|
||||
},
|
||||
FailureTTL: 0,
|
||||
InactivityTTL: 0,
|
||||
LockedTTL: 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)
|
||||
|
||||
if c.newDeadline.IsZero() {
|
||||
c.newDeadline = wsBuild.Deadline
|
||||
}
|
||||
require.WithinDuration(t, c.newDeadline, newBuild.Deadline, time.Second)
|
||||
require.WithinDuration(t, c.newMaxDeadline, newBuild.MaxDeadline, time.Second)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTemplateUpdateBuildDeadlinesSkip(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db, _ := dbtestutil.NewDB(t)
|
||||
|
||||
var (
|
||||
org = dbgen.Organization(t, db, database.Organization{})
|
||||
user = dbgen.User(t, db, database.User{})
|
||||
file = dbgen.File(t, db, database.File{
|
||||
CreatedBy: user.ID,
|
||||
})
|
||||
templateJob = dbgen.ProvisionerJob(t, db, database.ProvisionerJob{
|
||||
OrganizationID: org.ID,
|
||||
FileID: file.ID,
|
||||
InitiatorID: user.ID,
|
||||
Tags: database.StringMap{
|
||||
"foo": "bar",
|
||||
},
|
||||
})
|
||||
templateVersion = dbgen.TemplateVersion(t, db, database.TemplateVersion{
|
||||
OrganizationID: org.ID,
|
||||
CreatedBy: user.ID,
|
||||
JobID: templateJob.ID,
|
||||
})
|
||||
template = dbgen.Template(t, db, database.Template{
|
||||
OrganizationID: org.ID,
|
||||
ActiveVersionID: templateVersion.ID,
|
||||
CreatedBy: user.ID,
|
||||
})
|
||||
otherTemplate = dbgen.Template(t, db, database.Template{
|
||||
OrganizationID: org.ID,
|
||||
ActiveVersionID: templateVersion.ID,
|
||||
CreatedBy: user.ID,
|
||||
})
|
||||
)
|
||||
|
||||
// Create a workspace that will be shared by two builds.
|
||||
ws := dbgen.Workspace(t, db, database.Workspace{
|
||||
OrganizationID: org.ID,
|
||||
OwnerID: user.ID,
|
||||
TemplateID: template.ID,
|
||||
})
|
||||
|
||||
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
|
||||
}{
|
||||
{
|
||||
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.Workspace{
|
||||
OrganizationID: org.ID,
|
||||
OwnerID: user.ID,
|
||||
TemplateID: b.templateID,
|
||||
})
|
||||
wsID = ws.ID
|
||||
}
|
||||
job := dbgen.ProvisionerJob(t, db, database.ProvisionerJob{
|
||||
OrganizationID: org.ID,
|
||||
FileID: file.ID,
|
||||
InitiatorID: user.ID,
|
||||
Provisioner: database.ProvisionerTypeEcho,
|
||||
Tags: database.StringMap{
|
||||
wsID.String(): "yeah",
|
||||
},
|
||||
})
|
||||
wsBuild := dbgen.WorkspaceBuild(t, db, database.WorkspaceBuild{
|
||||
WorkspaceID: wsID,
|
||||
BuildNumber: b.buildNumber,
|
||||
JobID: job.ID,
|
||||
InitiatorID: user.ID,
|
||||
TemplateVersionID: templateVersion.ID,
|
||||
})
|
||||
|
||||
err := db.UpdateWorkspaceBuildByID(ctx, database.UpdateWorkspaceBuildByIDParams{
|
||||
ID: wsBuild.ID,
|
||||
UpdatedAt: buildTime,
|
||||
ProvisionerState: []byte{},
|
||||
Deadline: originalMaxDeadline,
|
||||
MaxDeadline: originalMaxDeadline,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
wsBuild, err = db.GetWorkspaceBuildByID(ctx, wsBuild.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
builds[i].wsBuild = wsBuild
|
||||
|
||||
if !b.buildStarted {
|
||||
continue
|
||||
}
|
||||
|
||||
acquiredJob, err := db.AcquireProvisionerJob(ctx, database.AcquireProvisionerJobParams{
|
||||
StartedAt: sql.NullTime{
|
||||
Time: buildTime,
|
||||
Valid: true,
|
||||
},
|
||||
WorkerID: uuid.NullUUID{
|
||||
UUID: uuid.New(),
|
||||
Valid: true,
|
||||
},
|
||||
Types: []database.ProvisionerType{database.ProvisionerTypeEcho},
|
||||
Tags: 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)
|
||||
}
|
||||
|
||||
userQuietHoursStore, err := schedule.NewEnterpriseUserQuietHoursScheduleStore(userQuietHoursSchedule)
|
||||
require.NoError(t, err)
|
||||
userQuietHoursStorePtr := &atomic.Pointer[agplschedule.UserQuietHoursScheduleStore]{}
|
||||
userQuietHoursStorePtr.Store(&userQuietHoursStore)
|
||||
|
||||
// Set the template policy.
|
||||
templateScheduleStore := schedule.NewEnterpriseTemplateScheduleStore(userQuietHoursStorePtr)
|
||||
templateScheduleStore.UseRestartRequirement.Store(true)
|
||||
templateScheduleStore.TimeNowFn = func() time.Time {
|
||||
return now
|
||||
}
|
||||
_, err = templateScheduleStore.Set(ctx, db, template, agplschedule.TemplateScheduleOptions{
|
||||
UserAutostartEnabled: false,
|
||||
UserAutostopEnabled: false,
|
||||
DefaultTTL: 0,
|
||||
MaxTTL: 0,
|
||||
UseRestartRequirement: true,
|
||||
RestartRequirement: agplschedule.TemplateRestartRequirement{
|
||||
// Every day
|
||||
DaysOfWeek: 0b01111111,
|
||||
Weeks: 0,
|
||||
},
|
||||
FailureTTL: 0,
|
||||
InactivityTTL: 0,
|
||||
LockedTTL: 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)
|
||||
}
|
||||
}
|
||||
}
|
@@ -9,6 +9,7 @@ import (
|
||||
|
||||
"github.com/coder/coder/coderd/database"
|
||||
agpl "github.com/coder/coder/coderd/schedule"
|
||||
"github.com/coder/coder/coderd/tracing"
|
||||
)
|
||||
|
||||
// enterpriseUserQuietHoursScheduleStore provides an
|
||||
@@ -29,7 +30,8 @@ func NewEnterpriseUserQuietHoursScheduleStore(defaultSchedule string) (agpl.User
|
||||
defaultSchedule: defaultSchedule,
|
||||
}
|
||||
|
||||
_, err := s.parseSchedule(defaultSchedule)
|
||||
// The context is only used for tracing so using a background ctx is fine.
|
||||
_, err := s.parseSchedule(context.Background(), defaultSchedule)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("parse default schedule: %w", err)
|
||||
}
|
||||
@@ -37,7 +39,10 @@ func NewEnterpriseUserQuietHoursScheduleStore(defaultSchedule string) (agpl.User
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *enterpriseUserQuietHoursScheduleStore) parseSchedule(rawSchedule string) (agpl.UserQuietHoursScheduleOptions, error) {
|
||||
func (s *enterpriseUserQuietHoursScheduleStore) parseSchedule(ctx context.Context, rawSchedule string) (agpl.UserQuietHoursScheduleOptions, error) {
|
||||
_, span := tracing.StartSpan(ctx)
|
||||
defer span.End()
|
||||
|
||||
userSet := true
|
||||
if strings.TrimSpace(rawSchedule) == "" {
|
||||
userSet = false
|
||||
@@ -64,16 +69,22 @@ func (s *enterpriseUserQuietHoursScheduleStore) parseSchedule(rawSchedule string
|
||||
}
|
||||
|
||||
func (s *enterpriseUserQuietHoursScheduleStore) Get(ctx context.Context, db database.Store, userID uuid.UUID) (agpl.UserQuietHoursScheduleOptions, error) {
|
||||
ctx, span := tracing.StartSpan(ctx)
|
||||
defer span.End()
|
||||
|
||||
user, err := db.GetUserByID(ctx, userID)
|
||||
if err != nil {
|
||||
return agpl.UserQuietHoursScheduleOptions{}, xerrors.Errorf("get user by ID: %w", err)
|
||||
}
|
||||
|
||||
return s.parseSchedule(user.QuietHoursSchedule)
|
||||
return s.parseSchedule(ctx, user.QuietHoursSchedule)
|
||||
}
|
||||
|
||||
func (s *enterpriseUserQuietHoursScheduleStore) Set(ctx context.Context, db database.Store, userID uuid.UUID, rawSchedule string) (agpl.UserQuietHoursScheduleOptions, error) {
|
||||
opts, err := s.parseSchedule(rawSchedule)
|
||||
ctx, span := tracing.StartSpan(ctx)
|
||||
defer span.End()
|
||||
|
||||
opts, err := s.parseSchedule(ctx, rawSchedule)
|
||||
if err != nil {
|
||||
return opts, err
|
||||
}
|
||||
@@ -91,8 +102,12 @@ func (s *enterpriseUserQuietHoursScheduleStore) Set(ctx context.Context, db data
|
||||
return agpl.UserQuietHoursScheduleOptions{}, xerrors.Errorf("update user quiet hours schedule: %w", err)
|
||||
}
|
||||
|
||||
// TODO(@dean): update max_deadline for all active builds for this user to clamp to
|
||||
// the new schedule.
|
||||
// We don't update workspace build deadlines when the user changes their own
|
||||
// quiet hours schedule, because they could potentially keep their workspace
|
||||
// running forever.
|
||||
//
|
||||
// Workspace build deadlines are updated when the template admin changes the
|
||||
// template's settings however.
|
||||
|
||||
return opts, nil
|
||||
}
|
||||
|
Reference in New Issue
Block a user