mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: update workspace deadline when template policy changes (#8964)
This commit is contained in:
@@ -784,6 +784,14 @@ func (q *querier) GetActiveUserCount(ctx context.Context) (int64, error) {
|
||||
return q.db.GetActiveUserCount(ctx)
|
||||
}
|
||||
|
||||
func (q *querier) GetActiveWorkspaceBuildsByTemplateID(ctx context.Context, templateID uuid.UUID) ([]database.WorkspaceBuild, error) {
|
||||
// This is a system-only function.
|
||||
if err := q.authorizeContext(ctx, rbac.ActionRead, rbac.ResourceSystem); err != nil {
|
||||
return []database.WorkspaceBuild{}, err
|
||||
}
|
||||
return q.db.GetActiveWorkspaceBuildsByTemplateID(ctx, templateID)
|
||||
}
|
||||
|
||||
func (q *querier) GetAllTailnetAgents(ctx context.Context) ([]database.TailnetAgent, error) {
|
||||
if err := q.authorizeContext(ctx, rbac.ActionRead, rbac.ResourceTailnetCoordinator); err != nil {
|
||||
return []database.TailnetAgent{}, err
|
||||
|
||||
@@ -918,6 +918,34 @@ func (q *FakeQuerier) GetActiveUserCount(_ context.Context) (int64, error) {
|
||||
return active, nil
|
||||
}
|
||||
|
||||
func (q *FakeQuerier) GetActiveWorkspaceBuildsByTemplateID(ctx context.Context, templateID uuid.UUID) ([]database.WorkspaceBuild, error) {
|
||||
workspaceIDs := func() []uuid.UUID {
|
||||
q.mutex.RLock()
|
||||
defer q.mutex.RUnlock()
|
||||
|
||||
ids := []uuid.UUID{}
|
||||
for _, workspace := range q.workspaces {
|
||||
if workspace.TemplateID == templateID {
|
||||
ids = append(ids, workspace.ID)
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}()
|
||||
|
||||
builds, err := q.GetLatestWorkspaceBuildsByWorkspaceIDs(ctx, workspaceIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
filteredBuilds := []database.WorkspaceBuild{}
|
||||
for _, build := range builds {
|
||||
if build.Transition == database.WorkspaceTransitionStart {
|
||||
filteredBuilds = append(filteredBuilds, build)
|
||||
}
|
||||
}
|
||||
return filteredBuilds, nil
|
||||
}
|
||||
|
||||
func (*FakeQuerier) GetAllTailnetAgents(_ context.Context) ([]database.TailnetAgent, error) {
|
||||
return nil, ErrUnimplemented
|
||||
}
|
||||
|
||||
@@ -237,6 +237,13 @@ func (m metricsStore) GetActiveUserCount(ctx context.Context) (int64, error) {
|
||||
return count, err
|
||||
}
|
||||
|
||||
func (m metricsStore) GetActiveWorkspaceBuildsByTemplateID(ctx context.Context, templateID uuid.UUID) ([]database.WorkspaceBuild, error) {
|
||||
start := time.Now()
|
||||
r0, r1 := m.s.GetActiveWorkspaceBuildsByTemplateID(ctx, templateID)
|
||||
m.queryLatencies.WithLabelValues("GetActiveWorkspaceBuildsByTemplateID").Observe(time.Since(start).Seconds())
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
func (m metricsStore) GetAllTailnetAgents(ctx context.Context) ([]database.TailnetAgent, error) {
|
||||
start := time.Now()
|
||||
r0, r1 := m.s.GetAllTailnetAgents(ctx)
|
||||
|
||||
@@ -371,6 +371,21 @@ func (mr *MockStoreMockRecorder) GetActiveUserCount(arg0 interface{}) *gomock.Ca
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetActiveUserCount", reflect.TypeOf((*MockStore)(nil).GetActiveUserCount), arg0)
|
||||
}
|
||||
|
||||
// GetActiveWorkspaceBuildsByTemplateID mocks base method.
|
||||
func (m *MockStore) GetActiveWorkspaceBuildsByTemplateID(arg0 context.Context, arg1 uuid.UUID) ([]database.WorkspaceBuild, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "GetActiveWorkspaceBuildsByTemplateID", arg0, arg1)
|
||||
ret0, _ := ret[0].([]database.WorkspaceBuild)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// GetActiveWorkspaceBuildsByTemplateID indicates an expected call of GetActiveWorkspaceBuildsByTemplateID.
|
||||
func (mr *MockStoreMockRecorder) GetActiveWorkspaceBuildsByTemplateID(arg0, arg1 interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetActiveWorkspaceBuildsByTemplateID", reflect.TypeOf((*MockStore)(nil).GetActiveWorkspaceBuildsByTemplateID), arg0, arg1)
|
||||
}
|
||||
|
||||
// GetAllTailnetAgents mocks base method.
|
||||
func (m *MockStore) GetAllTailnetAgents(arg0 context.Context) ([]database.TailnetAgent, error) {
|
||||
m.ctrl.T.Helper()
|
||||
|
||||
@@ -48,6 +48,7 @@ type sqlcQuerier interface {
|
||||
GetAPIKeysByUserID(ctx context.Context, arg GetAPIKeysByUserIDParams) ([]APIKey, error)
|
||||
GetAPIKeysLastUsedAfter(ctx context.Context, lastUsed time.Time) ([]APIKey, error)
|
||||
GetActiveUserCount(ctx context.Context) (int64, error)
|
||||
GetActiveWorkspaceBuildsByTemplateID(ctx context.Context, templateID uuid.UUID) ([]WorkspaceBuild, error)
|
||||
GetAllTailnetAgents(ctx context.Context) ([]TailnetAgent, error)
|
||||
GetAllTailnetClients(ctx context.Context) ([]TailnetClient, error)
|
||||
GetAppSecurityKey(ctx context.Context) (string, error)
|
||||
|
||||
@@ -7884,6 +7884,72 @@ func (q *sqlQuerier) InsertWorkspaceBuildParameters(ctx context.Context, arg Ins
|
||||
return err
|
||||
}
|
||||
|
||||
const getActiveWorkspaceBuildsByTemplateID = `-- name: GetActiveWorkspaceBuildsByTemplateID :many
|
||||
SELECT wb.id, wb.created_at, wb.updated_at, wb.workspace_id, wb.template_version_id, wb.build_number, wb.transition, wb.initiator_id, wb.provisioner_state, wb.job_id, wb.deadline, wb.reason, wb.daily_cost, wb.max_deadline, wb.initiator_by_avatar_url, wb.initiator_by_username
|
||||
FROM (
|
||||
SELECT
|
||||
workspace_id, MAX(build_number) as max_build_number
|
||||
FROM
|
||||
workspace_build_with_user AS workspace_builds
|
||||
WHERE
|
||||
workspace_id IN (
|
||||
SELECT
|
||||
id
|
||||
FROM
|
||||
workspaces
|
||||
WHERE
|
||||
template_id = $1
|
||||
)
|
||||
GROUP BY
|
||||
workspace_id
|
||||
) m
|
||||
JOIN
|
||||
workspace_build_with_user AS wb
|
||||
ON m.workspace_id = wb.workspace_id AND m.max_build_number = wb.build_number
|
||||
WHERE
|
||||
wb.transition = 'start'::workspace_transition
|
||||
`
|
||||
|
||||
func (q *sqlQuerier) GetActiveWorkspaceBuildsByTemplateID(ctx context.Context, templateID uuid.UUID) ([]WorkspaceBuild, error) {
|
||||
rows, err := q.db.QueryContext(ctx, getActiveWorkspaceBuildsByTemplateID, templateID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []WorkspaceBuild
|
||||
for rows.Next() {
|
||||
var i WorkspaceBuild
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.WorkspaceID,
|
||||
&i.TemplateVersionID,
|
||||
&i.BuildNumber,
|
||||
&i.Transition,
|
||||
&i.InitiatorID,
|
||||
&i.ProvisionerState,
|
||||
&i.JobID,
|
||||
&i.Deadline,
|
||||
&i.Reason,
|
||||
&i.DailyCost,
|
||||
&i.MaxDeadline,
|
||||
&i.InitiatorByAvatarUrl,
|
||||
&i.InitiatorByUsername,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const getLatestWorkspaceBuildByWorkspaceID = `-- name: GetLatestWorkspaceBuildByWorkspaceID :one
|
||||
SELECT
|
||||
id, created_at, updated_at, workspace_id, template_version_id, build_number, transition, initiator_id, provisioner_state, job_id, deadline, reason, daily_cost, max_deadline, initiator_by_avatar_url, initiator_by_username
|
||||
|
||||
@@ -144,3 +144,27 @@ SET
|
||||
WHERE
|
||||
id = $1;
|
||||
|
||||
-- name: GetActiveWorkspaceBuildsByTemplateID :many
|
||||
SELECT wb.*
|
||||
FROM (
|
||||
SELECT
|
||||
workspace_id, MAX(build_number) as max_build_number
|
||||
FROM
|
||||
workspace_build_with_user AS workspace_builds
|
||||
WHERE
|
||||
workspace_id IN (
|
||||
SELECT
|
||||
id
|
||||
FROM
|
||||
workspaces
|
||||
WHERE
|
||||
template_id = $1
|
||||
)
|
||||
GROUP BY
|
||||
workspace_id
|
||||
) m
|
||||
JOIN
|
||||
workspace_build_with_user AS wb
|
||||
ON m.workspace_id = wb.workspace_id AND m.max_build_number = wb.build_number
|
||||
WHERE
|
||||
wb.transition = 'start'::workspace_transition;
|
||||
|
||||
@@ -4,9 +4,12 @@ import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"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/tracing"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -72,6 +75,13 @@ type AutostopTime struct {
|
||||
// Deadline is a cost saving measure, while max deadline is a
|
||||
// compliance/updating measure.
|
||||
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
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/coderd/database"
|
||||
"github.com/coder/coder/coderd/tracing"
|
||||
)
|
||||
|
||||
const MaxTemplateRestartRequirementWeeks = 16
|
||||
@@ -122,6 +123,9 @@ func NewAGPLTemplateScheduleStore() TemplateScheduleStore {
|
||||
}
|
||||
|
||||
func (*agplTemplateScheduleStore) Get(ctx context.Context, db database.Store, templateID uuid.UUID) (TemplateScheduleOptions, error) {
|
||||
ctx, span := tracing.StartSpan(ctx)
|
||||
defer span.End()
|
||||
|
||||
tpl, err := db.GetTemplateByID(ctx, templateID)
|
||||
if err != nil {
|
||||
return TemplateScheduleOptions{}, err
|
||||
@@ -148,6 +152,9 @@ func (*agplTemplateScheduleStore) Get(ctx context.Context, db database.Store, te
|
||||
}
|
||||
|
||||
func (*agplTemplateScheduleStore) Set(ctx context.Context, db database.Store, tpl database.Template, opts TemplateScheduleOptions) (database.Template, error) {
|
||||
ctx, span := tracing.StartSpan(ctx)
|
||||
defer span.End()
|
||||
|
||||
if int64(opts.DefaultTTL) == tpl.DefaultTTL {
|
||||
// Avoid updating the UpdatedAt timestamp if nothing will be changed.
|
||||
return tpl, nil
|
||||
|
||||
@@ -507,7 +507,7 @@ func (api *API) updateEntitlements(ctx context.Context) error {
|
||||
|
||||
if initial, changed, enabled := featureChanged(codersdk.FeatureAdvancedTemplateScheduling); shouldUpdate(initial, changed, enabled) {
|
||||
if enabled {
|
||||
templateStore := schedule.NewEnterpriseTemplateScheduleStore()
|
||||
templateStore := schedule.NewEnterpriseTemplateScheduleStore(api.AGPL.UserQuietHoursScheduleStore)
|
||||
templateStoreInterface := agplschedule.TemplateScheduleStore(templateStore)
|
||||
api.AGPL.TemplateScheduleStore.Store(&templateStoreInterface)
|
||||
} else {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -24,6 +25,16 @@ import (
|
||||
"github.com/coder/coder/testutil"
|
||||
)
|
||||
|
||||
// agplUserQuietHoursScheduleStore is passed to
|
||||
// NewEnterpriseTemplateScheduleStore as we don't care about updating the
|
||||
// schedule and having it recalculate the build deadline in these tests.
|
||||
func agplUserQuietHoursScheduleStore() *atomic.Pointer[agplschedule.UserQuietHoursScheduleStore] {
|
||||
store := agplschedule.NewAGPLUserQuietHoursScheduleStore()
|
||||
p := &atomic.Pointer[agplschedule.UserQuietHoursScheduleStore]{}
|
||||
p.Store(&store)
|
||||
return p
|
||||
}
|
||||
|
||||
func TestCreateWorkspace(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -100,7 +111,7 @@ func TestWorkspaceAutobuild(t *testing.T) {
|
||||
AutobuildTicker: ticker,
|
||||
IncludeProvisionerDaemon: true,
|
||||
AutobuildStats: statCh,
|
||||
TemplateScheduleStore: schedule.NewEnterpriseTemplateScheduleStore(),
|
||||
TemplateScheduleStore: schedule.NewEnterpriseTemplateScheduleStore(agplUserQuietHoursScheduleStore()),
|
||||
},
|
||||
LicenseOptions: &coderdenttest.LicenseOptions{
|
||||
Features: license.Features{codersdk.FeatureAdvancedTemplateScheduling: 1},
|
||||
@@ -147,7 +158,7 @@ func TestWorkspaceAutobuild(t *testing.T) {
|
||||
AutobuildTicker: ticker,
|
||||
IncludeProvisionerDaemon: true,
|
||||
AutobuildStats: statCh,
|
||||
TemplateScheduleStore: schedule.NewEnterpriseTemplateScheduleStore(),
|
||||
TemplateScheduleStore: schedule.NewEnterpriseTemplateScheduleStore(agplUserQuietHoursScheduleStore()),
|
||||
},
|
||||
LicenseOptions: &coderdenttest.LicenseOptions{
|
||||
Features: license.Features{codersdk.FeatureAdvancedTemplateScheduling: 1},
|
||||
@@ -193,7 +204,7 @@ func TestWorkspaceAutobuild(t *testing.T) {
|
||||
AutobuildTicker: ticker,
|
||||
IncludeProvisionerDaemon: true,
|
||||
AutobuildStats: statCh,
|
||||
TemplateScheduleStore: schedule.NewEnterpriseTemplateScheduleStore(),
|
||||
TemplateScheduleStore: schedule.NewEnterpriseTemplateScheduleStore(agplUserQuietHoursScheduleStore()),
|
||||
},
|
||||
LicenseOptions: &coderdenttest.LicenseOptions{
|
||||
Features: license.Features{codersdk.FeatureAdvancedTemplateScheduling: 1},
|
||||
@@ -235,7 +246,7 @@ func TestWorkspaceAutobuild(t *testing.T) {
|
||||
AutobuildTicker: ticker,
|
||||
IncludeProvisionerDaemon: true,
|
||||
AutobuildStats: statCh,
|
||||
TemplateScheduleStore: schedule.NewEnterpriseTemplateScheduleStore(),
|
||||
TemplateScheduleStore: schedule.NewEnterpriseTemplateScheduleStore(agplUserQuietHoursScheduleStore()),
|
||||
},
|
||||
LicenseOptions: &coderdenttest.LicenseOptions{
|
||||
Features: license.Features{codersdk.FeatureAdvancedTemplateScheduling: 1},
|
||||
@@ -292,7 +303,7 @@ func TestWorkspaceAutobuild(t *testing.T) {
|
||||
AutobuildTicker: ticker,
|
||||
IncludeProvisionerDaemon: true,
|
||||
AutobuildStats: statCh,
|
||||
TemplateScheduleStore: schedule.NewEnterpriseTemplateScheduleStore(),
|
||||
TemplateScheduleStore: schedule.NewEnterpriseTemplateScheduleStore(agplUserQuietHoursScheduleStore()),
|
||||
},
|
||||
LicenseOptions: &coderdenttest.LicenseOptions{
|
||||
Features: license.Features{codersdk.FeatureAdvancedTemplateScheduling: 1},
|
||||
@@ -334,7 +345,7 @@ func TestWorkspaceAutobuild(t *testing.T) {
|
||||
AutobuildTicker: ticker,
|
||||
IncludeProvisionerDaemon: true,
|
||||
AutobuildStats: statCh,
|
||||
TemplateScheduleStore: schedule.NewEnterpriseTemplateScheduleStore(),
|
||||
TemplateScheduleStore: schedule.NewEnterpriseTemplateScheduleStore(agplUserQuietHoursScheduleStore()),
|
||||
},
|
||||
LicenseOptions: &coderdenttest.LicenseOptions{
|
||||
Features: license.Features{codersdk.FeatureAdvancedTemplateScheduling: 1},
|
||||
@@ -376,7 +387,7 @@ func TestWorkspaceAutobuild(t *testing.T) {
|
||||
AutobuildTicker: ticker,
|
||||
IncludeProvisionerDaemon: true,
|
||||
AutobuildStats: statCh,
|
||||
TemplateScheduleStore: schedule.NewEnterpriseTemplateScheduleStore(),
|
||||
TemplateScheduleStore: schedule.NewEnterpriseTemplateScheduleStore(agplUserQuietHoursScheduleStore()),
|
||||
},
|
||||
LicenseOptions: &coderdenttest.LicenseOptions{
|
||||
Features: license.Features{codersdk.FeatureAdvancedTemplateScheduling: 1},
|
||||
@@ -427,7 +438,7 @@ func TestWorkspaceAutobuild(t *testing.T) {
|
||||
AutobuildTicker: ticker,
|
||||
IncludeProvisionerDaemon: true,
|
||||
AutobuildStats: statCh,
|
||||
TemplateScheduleStore: schedule.NewEnterpriseTemplateScheduleStore(),
|
||||
TemplateScheduleStore: schedule.NewEnterpriseTemplateScheduleStore(agplUserQuietHoursScheduleStore()),
|
||||
},
|
||||
LicenseOptions: &coderdenttest.LicenseOptions{
|
||||
Features: license.Features{codersdk.FeatureAdvancedTemplateScheduling: 1},
|
||||
@@ -497,7 +508,7 @@ func TestWorkspaceAutobuild(t *testing.T) {
|
||||
AutobuildTicker: ticker,
|
||||
IncludeProvisionerDaemon: true,
|
||||
AutobuildStats: statCh,
|
||||
TemplateScheduleStore: schedule.NewEnterpriseTemplateScheduleStore(),
|
||||
TemplateScheduleStore: schedule.NewEnterpriseTemplateScheduleStore(agplUserQuietHoursScheduleStore()),
|
||||
},
|
||||
LicenseOptions: &coderdenttest.LicenseOptions{
|
||||
Features: license.Features{codersdk.FeatureAdvancedTemplateScheduling: 1},
|
||||
@@ -558,7 +569,7 @@ func TestWorkspaceAutobuild(t *testing.T) {
|
||||
AutobuildTicker: tickCh,
|
||||
IncludeProvisionerDaemon: true,
|
||||
AutobuildStats: statsCh,
|
||||
TemplateScheduleStore: schedule.NewEnterpriseTemplateScheduleStore(),
|
||||
TemplateScheduleStore: schedule.NewEnterpriseTemplateScheduleStore(agplUserQuietHoursScheduleStore()),
|
||||
},
|
||||
LicenseOptions: &coderdenttest.LicenseOptions{
|
||||
Features: license.Features{codersdk.FeatureAdvancedTemplateScheduling: 1},
|
||||
|
||||
Reference in New Issue
Block a user