diff --git a/cli/testdata/coder_list_--output_json.golden b/cli/testdata/coder_list_--output_json.golden
index 8f45fd79cfd5a..6fe0a72311220 100644
--- a/cli/testdata/coder_list_--output_json.golden
+++ b/cli/testdata/coder_list_--output_json.golden
@@ -65,6 +65,7 @@
},
"automatic_updates": "never",
"allow_renames": false,
- "favorite": false
+ "favorite": false,
+ "next_start_at": "[timestamp]"
}
]
diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go
index f814b25d99337..5f53a341fd690 100644
--- a/coderd/apidoc/docs.go
+++ b/coderd/apidoc/docs.go
@@ -14500,6 +14500,10 @@ const docTemplate = `{
"name": {
"type": "string"
},
+ "next_start_at": {
+ "type": "string",
+ "format": "date-time"
+ },
"organization_id": {
"type": "string",
"format": "uuid"
diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json
index 4f439e472fa7b..4130702d8e521 100644
--- a/coderd/apidoc/swagger.json
+++ b/coderd/apidoc/swagger.json
@@ -13179,6 +13179,10 @@
"name": {
"type": "string"
},
+ "next_start_at": {
+ "type": "string",
+ "format": "date-time"
+ },
"organization_id": {
"type": "string",
"format": "uuid"
diff --git a/coderd/autobuild/lifecycle_executor.go b/coderd/autobuild/lifecycle_executor.go
index ac2930c9e32c8..66e577f3a8603 100644
--- a/coderd/autobuild/lifecycle_executor.go
+++ b/coderd/autobuild/lifecycle_executor.go
@@ -142,7 +142,7 @@ func (e *Executor) runOnce(t time.Time) Stats {
// NOTE: If a workspace build is created with a given TTL and then the user either
// changes or unsets the TTL, the deadline for the workspace build will not
// have changed. This behavior is as expected per #2229.
- workspaces, err := e.db.GetWorkspacesEligibleForTransition(e.ctx, t)
+ workspaces, err := e.db.GetWorkspacesEligibleForTransition(e.ctx, currentTick)
if err != nil {
e.log.Error(e.ctx, "get workspaces for autostart or autostop", slog.Error(err))
return stats
@@ -205,6 +205,23 @@ func (e *Executor) runOnce(t time.Time) Stats {
return xerrors.Errorf("get template scheduling options: %w", err)
}
+ // If next start at is not valid we need to re-compute it
+ if !ws.NextStartAt.Valid && ws.AutostartSchedule.Valid {
+ next, err := schedule.NextAllowedAutostart(currentTick, ws.AutostartSchedule.String, templateSchedule)
+ if err == nil {
+ nextStartAt := sql.NullTime{Valid: true, Time: dbtime.Time(next.UTC())}
+ if err = tx.UpdateWorkspaceNextStartAt(e.ctx, database.UpdateWorkspaceNextStartAtParams{
+ ID: wsID,
+ NextStartAt: nextStartAt,
+ }); err != nil {
+ return xerrors.Errorf("update workspace next start at: %w", err)
+ }
+
+ // Save re-fetching the workspace
+ ws.NextStartAt = nextStartAt
+ }
+ }
+
tmpl, err = tx.GetTemplateByID(e.ctx, ws.TemplateID)
if err != nil {
return xerrors.Errorf("get template by ID: %w", err)
@@ -463,8 +480,8 @@ func isEligibleForAutostart(user database.User, ws database.Workspace, build dat
return false
}
- nextTransition, allowed := schedule.NextAutostart(build.CreatedAt, ws.AutostartSchedule.String, templateSchedule)
- if !allowed {
+ nextTransition, err := schedule.NextAllowedAutostart(build.CreatedAt, ws.AutostartSchedule.String, templateSchedule)
+ if err != nil {
return false
}
diff --git a/coderd/autobuild/lifecycle_executor_test.go b/coderd/autobuild/lifecycle_executor_test.go
index 667b20dd9fd4f..794d99f778446 100644
--- a/coderd/autobuild/lifecycle_executor_test.go
+++ b/coderd/autobuild/lifecycle_executor_test.go
@@ -1083,6 +1083,10 @@ func TestNotifications(t *testing.T) {
IncludeProvisionerDaemon: true,
NotificationsEnqueuer: ¬ifyEnq,
TemplateScheduleStore: schedule.MockTemplateScheduleStore{
+ SetFn: func(ctx context.Context, db database.Store, template database.Template, options schedule.TemplateScheduleOptions) (database.Template, error) {
+ template.TimeTilDormant = int64(options.TimeTilDormant)
+ return schedule.NewAGPLTemplateScheduleStore().Set(ctx, db, template, options)
+ },
GetFn: func(_ context.Context, _ database.Store, _ uuid.UUID) (schedule.TemplateScheduleOptions, error) {
return schedule.TemplateScheduleOptions{
UserAutostartEnabled: false,
@@ -1099,7 +1103,9 @@ func TestNotifications(t *testing.T) {
)
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
- template := coderdtest.CreateTemplate(t, client, admin.OrganizationID, version.ID)
+ template := coderdtest.CreateTemplate(t, client, admin.OrganizationID, version.ID, func(ctr *codersdk.CreateTemplateRequest) {
+ ctr.TimeTilDormantMillis = ptr.Ref(timeTilDormant.Milliseconds())
+ })
userClient, _ := coderdtest.CreateAnotherUser(t, client, admin.OrganizationID)
workspace := coderdtest.CreateWorkspace(t, userClient, template.ID)
coderdtest.AwaitWorkspaceBuildJobCompleted(t, userClient, workspace.LatestBuild.ID)
diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go
index 58c9179da5e4b..18400ed5b115e 100644
--- a/coderd/database/dbauthz/dbauthz.go
+++ b/coderd/database/dbauthz/dbauthz.go
@@ -1030,6 +1030,13 @@ func (q *querier) BatchUpdateWorkspaceLastUsedAt(ctx context.Context, arg databa
return q.db.BatchUpdateWorkspaceLastUsedAt(ctx, arg)
}
+func (q *querier) BatchUpdateWorkspaceNextStartAt(ctx context.Context, arg database.BatchUpdateWorkspaceNextStartAtParams) error {
+ if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceWorkspace.All()); err != nil {
+ return err
+ }
+ return q.db.BatchUpdateWorkspaceNextStartAt(ctx, arg)
+}
+
func (q *querier) BulkMarkNotificationMessagesFailed(ctx context.Context, arg database.BulkMarkNotificationMessagesFailedParams) (int64, error) {
if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceNotificationMessage); err != nil {
return 0, err
@@ -2817,6 +2824,13 @@ func (q *querier) GetWorkspacesAndAgentsByOwnerID(ctx context.Context, ownerID u
return q.db.GetAuthorizedWorkspacesAndAgentsByOwnerID(ctx, ownerID, prep)
}
+func (q *querier) GetWorkspacesByTemplateID(ctx context.Context, templateID uuid.UUID) ([]database.WorkspaceTable, error) {
+ if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceSystem); err != nil {
+ return nil, err
+ }
+ return q.db.GetWorkspacesByTemplateID(ctx, templateID)
+}
+
func (q *querier) GetWorkspacesEligibleForTransition(ctx context.Context, now time.Time) ([]database.GetWorkspacesEligibleForTransitionRow, error) {
return q.db.GetWorkspacesEligibleForTransition(ctx, now)
}
@@ -4062,6 +4076,13 @@ func (q *querier) UpdateWorkspaceLastUsedAt(ctx context.Context, arg database.Up
return update(q.log, q.auth, fetch, q.db.UpdateWorkspaceLastUsedAt)(ctx, arg)
}
+func (q *querier) UpdateWorkspaceNextStartAt(ctx context.Context, arg database.UpdateWorkspaceNextStartAtParams) error {
+ fetch := func(ctx context.Context, arg database.UpdateWorkspaceNextStartAtParams) (database.Workspace, error) {
+ return q.db.GetWorkspaceByID(ctx, arg.ID)
+ }
+ return update(q.log, q.auth, fetch, q.db.UpdateWorkspaceNextStartAt)(ctx, arg)
+}
+
func (q *querier) UpdateWorkspaceProxy(ctx context.Context, arg database.UpdateWorkspaceProxyParams) (database.WorkspaceProxy, error) {
fetch := func(ctx context.Context, arg database.UpdateWorkspaceProxyParams) (database.WorkspaceProxy, error) {
return q.db.GetWorkspaceProxyByID(ctx, arg.ID)
diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go
index 638829ae24ae5..3b40d42901c86 100644
--- a/coderd/database/dbauthz/dbauthz_test.go
+++ b/coderd/database/dbauthz/dbauthz_test.go
@@ -1908,6 +1908,19 @@ func (s *MethodTestSuite) TestWorkspace() {
ID: ws.ID,
}).Asserts(ws, policy.ActionUpdate).Returns()
}))
+ s.Run("UpdateWorkspaceNextStartAt", s.Subtest(func(db database.Store, check *expects) {
+ ws := dbgen.Workspace(s.T(), db, database.WorkspaceTable{})
+ check.Args(database.UpdateWorkspaceNextStartAtParams{
+ ID: ws.ID,
+ NextStartAt: sql.NullTime{Valid: true, Time: dbtime.Now()},
+ }).Asserts(ws, policy.ActionUpdate)
+ }))
+ s.Run("BatchUpdateWorkspaceNextStartAt", s.Subtest(func(db database.Store, check *expects) {
+ check.Args(database.BatchUpdateWorkspaceNextStartAtParams{
+ IDs: []uuid.UUID{uuid.New()},
+ NextStartAts: []time.Time{dbtime.Now()},
+ }).Asserts(rbac.ResourceWorkspace.All(), policy.ActionUpdate)
+ }))
s.Run("BatchUpdateWorkspaceLastUsedAt", s.Subtest(func(db database.Store, check *expects) {
ws1 := dbgen.Workspace(s.T(), db, database.WorkspaceTable{})
ws2 := dbgen.Workspace(s.T(), db, database.WorkspaceTable{})
@@ -2784,6 +2797,9 @@ func (s *MethodTestSuite) TestSystemFunctions() {
s.Run("GetTemplateAverageBuildTime", s.Subtest(func(db database.Store, check *expects) {
check.Args(database.GetTemplateAverageBuildTimeParams{}).Asserts(rbac.ResourceSystem, policy.ActionRead)
}))
+ s.Run("GetWorkspacesByTemplateID", s.Subtest(func(db database.Store, check *expects) {
+ check.Args(uuid.Nil).Asserts(rbac.ResourceSystem, policy.ActionRead)
+ }))
s.Run("GetWorkspacesEligibleForTransition", s.Subtest(func(db database.Store, check *expects) {
check.Args(time.Time{}).Asserts()
}))
diff --git a/coderd/database/dbgen/dbgen.go b/coderd/database/dbgen/dbgen.go
index 9c8696112dea8..f8ca54bb9df83 100644
--- a/coderd/database/dbgen/dbgen.go
+++ b/coderd/database/dbgen/dbgen.go
@@ -260,6 +260,7 @@ func Workspace(t testing.TB, db database.Store, orig database.WorkspaceTable) da
AutostartSchedule: orig.AutostartSchedule,
Ttl: orig.Ttl,
AutomaticUpdates: takeFirst(orig.AutomaticUpdates, database.AutomaticUpdatesNever),
+ NextStartAt: orig.NextStartAt,
})
require.NoError(t, err, "insert workspace")
return workspace
diff --git a/coderd/database/dbmem/dbmem.go b/coderd/database/dbmem/dbmem.go
index 765573b311a84..2d3e11bf67622 100644
--- a/coderd/database/dbmem/dbmem.go
+++ b/coderd/database/dbmem/dbmem.go
@@ -475,6 +475,7 @@ func (q *FakeQuerier) convertToWorkspaceRowsNoLock(ctx context.Context, workspac
DeletingAt: w.DeletingAt,
AutomaticUpdates: w.AutomaticUpdates,
Favorite: w.Favorite,
+ NextStartAt: w.NextStartAt,
OwnerAvatarUrl: extended.OwnerAvatarUrl,
OwnerUsername: extended.OwnerUsername,
@@ -1431,6 +1432,35 @@ func (q *FakeQuerier) BatchUpdateWorkspaceLastUsedAt(_ context.Context, arg data
return nil
}
+func (q *FakeQuerier) BatchUpdateWorkspaceNextStartAt(_ context.Context, arg database.BatchUpdateWorkspaceNextStartAtParams) error {
+ err := validateDatabaseType(arg)
+ if err != nil {
+ return err
+ }
+
+ q.mutex.Lock()
+ defer q.mutex.Unlock()
+
+ for i, workspace := range q.workspaces {
+ for j, workspaceID := range arg.IDs {
+ if workspace.ID != workspaceID {
+ continue
+ }
+
+ nextStartAt := arg.NextStartAts[j]
+ if nextStartAt.IsZero() {
+ q.workspaces[i].NextStartAt = sql.NullTime{}
+ } else {
+ q.workspaces[i].NextStartAt = sql.NullTime{Valid: true, Time: nextStartAt}
+ }
+
+ break
+ }
+ }
+
+ return nil
+}
+
func (*FakeQuerier) BulkMarkNotificationMessagesFailed(_ context.Context, arg database.BulkMarkNotificationMessagesFailedParams) (int64, error) {
err := validateDatabaseType(arg)
if err != nil {
@@ -6908,6 +6938,20 @@ func (q *FakeQuerier) GetWorkspacesAndAgentsByOwnerID(ctx context.Context, owner
return q.GetAuthorizedWorkspacesAndAgentsByOwnerID(ctx, ownerID, nil)
}
+func (q *FakeQuerier) GetWorkspacesByTemplateID(_ context.Context, templateID uuid.UUID) ([]database.WorkspaceTable, error) {
+ q.mutex.RLock()
+ defer q.mutex.RUnlock()
+
+ workspaces := []database.WorkspaceTable{}
+ for _, workspace := range q.workspaces {
+ if workspace.TemplateID == templateID {
+ workspaces = append(workspaces, workspace)
+ }
+ }
+
+ return workspaces, nil
+}
+
func (q *FakeQuerier) GetWorkspacesEligibleForTransition(ctx context.Context, now time.Time) ([]database.GetWorkspacesEligibleForTransitionRow, error) {
q.mutex.RLock()
defer q.mutex.RUnlock()
@@ -6952,7 +6996,13 @@ func (q *FakeQuerier) GetWorkspacesEligibleForTransition(ctx context.Context, no
if user.Status == database.UserStatusActive &&
job.JobStatus != database.ProvisionerJobStatusFailed &&
build.Transition == database.WorkspaceTransitionStop &&
- workspace.AutostartSchedule.Valid {
+ workspace.AutostartSchedule.Valid &&
+ // We do not know if workspace with a zero next start is eligible
+ // for autostart, so we accept this false-positive. This can occur
+ // when a coder version is upgraded and next_start_at has yet to
+ // be set.
+ (workspace.NextStartAt.Time.IsZero() ||
+ !now.Before(workspace.NextStartAt.Time)) {
workspaces = append(workspaces, database.GetWorkspacesEligibleForTransitionRow{
ID: workspace.ID,
Name: workspace.Name,
@@ -6962,7 +7012,7 @@ func (q *FakeQuerier) GetWorkspacesEligibleForTransition(ctx context.Context, no
if !workspace.DormantAt.Valid &&
template.TimeTilDormant > 0 &&
- now.Sub(workspace.LastUsedAt) > time.Duration(template.TimeTilDormant) {
+ now.Sub(workspace.LastUsedAt) >= time.Duration(template.TimeTilDormant) {
workspaces = append(workspaces, database.GetWorkspacesEligibleForTransitionRow{
ID: workspace.ID,
Name: workspace.Name,
@@ -7927,6 +7977,7 @@ func (q *FakeQuerier) InsertWorkspace(_ context.Context, arg database.InsertWork
Ttl: arg.Ttl,
LastUsedAt: arg.LastUsedAt,
AutomaticUpdates: arg.AutomaticUpdates,
+ NextStartAt: arg.NextStartAt,
}
q.workspaces = append(q.workspaces, workspace)
return workspace, nil
@@ -9868,6 +9919,7 @@ func (q *FakeQuerier) UpdateWorkspaceAutostart(_ context.Context, arg database.U
continue
}
workspace.AutostartSchedule = arg.AutostartSchedule
+ workspace.NextStartAt = arg.NextStartAt
q.workspaces[index] = workspace
return nil
}
@@ -10017,6 +10069,29 @@ func (q *FakeQuerier) UpdateWorkspaceLastUsedAt(_ context.Context, arg database.
return sql.ErrNoRows
}
+func (q *FakeQuerier) UpdateWorkspaceNextStartAt(_ context.Context, arg database.UpdateWorkspaceNextStartAtParams) error {
+ err := validateDatabaseType(arg)
+ if err != nil {
+ return err
+ }
+
+ q.mutex.Lock()
+ defer q.mutex.Unlock()
+
+ for index, workspace := range q.workspaces {
+ if workspace.ID != arg.ID {
+ continue
+ }
+
+ workspace.NextStartAt = arg.NextStartAt
+ q.workspaces[index] = workspace
+
+ return nil
+ }
+
+ return sql.ErrNoRows
+}
+
func (q *FakeQuerier) UpdateWorkspaceProxy(_ context.Context, arg database.UpdateWorkspaceProxyParams) (database.WorkspaceProxy, error) {
q.mutex.Lock()
defer q.mutex.Unlock()
diff --git a/coderd/database/dbmetrics/querymetrics.go b/coderd/database/dbmetrics/querymetrics.go
index efde94488828f..844ce2b1e0608 100644
--- a/coderd/database/dbmetrics/querymetrics.go
+++ b/coderd/database/dbmetrics/querymetrics.go
@@ -126,6 +126,13 @@ func (m queryMetricsStore) BatchUpdateWorkspaceLastUsedAt(ctx context.Context, a
return r0
}
+func (m queryMetricsStore) BatchUpdateWorkspaceNextStartAt(ctx context.Context, arg database.BatchUpdateWorkspaceNextStartAtParams) error {
+ start := time.Now()
+ r0 := m.s.BatchUpdateWorkspaceNextStartAt(ctx, arg)
+ m.queryLatencies.WithLabelValues("BatchUpdateWorkspaceNextStartAt").Observe(time.Since(start).Seconds())
+ return r0
+}
+
func (m queryMetricsStore) BulkMarkNotificationMessagesFailed(ctx context.Context, arg database.BulkMarkNotificationMessagesFailedParams) (int64, error) {
start := time.Now()
r0, r1 := m.s.BulkMarkNotificationMessagesFailed(ctx, arg)
@@ -1673,6 +1680,13 @@ func (m queryMetricsStore) GetWorkspacesAndAgentsByOwnerID(ctx context.Context,
return r0, r1
}
+func (m queryMetricsStore) GetWorkspacesByTemplateID(ctx context.Context, templateID uuid.UUID) ([]database.WorkspaceTable, error) {
+ start := time.Now()
+ r0, r1 := m.s.GetWorkspacesByTemplateID(ctx, templateID)
+ m.queryLatencies.WithLabelValues("GetWorkspacesByTemplateID").Observe(time.Since(start).Seconds())
+ return r0, r1
+}
+
func (m queryMetricsStore) GetWorkspacesEligibleForTransition(ctx context.Context, now time.Time) ([]database.GetWorkspacesEligibleForTransitionRow, error) {
start := time.Now()
workspaces, err := m.s.GetWorkspacesEligibleForTransition(ctx, now)
@@ -2541,6 +2555,13 @@ func (m queryMetricsStore) UpdateWorkspaceLastUsedAt(ctx context.Context, arg da
return err
}
+func (m queryMetricsStore) UpdateWorkspaceNextStartAt(ctx context.Context, arg database.UpdateWorkspaceNextStartAtParams) error {
+ start := time.Now()
+ r0 := m.s.UpdateWorkspaceNextStartAt(ctx, arg)
+ m.queryLatencies.WithLabelValues("UpdateWorkspaceNextStartAt").Observe(time.Since(start).Seconds())
+ return r0
+}
+
func (m queryMetricsStore) UpdateWorkspaceProxy(ctx context.Context, arg database.UpdateWorkspaceProxyParams) (database.WorkspaceProxy, error) {
start := time.Now()
proxy, err := m.s.UpdateWorkspaceProxy(ctx, arg)
diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go
index eefa89c86b57f..956d8fad5eabf 100644
--- a/coderd/database/dbmock/dbmock.go
+++ b/coderd/database/dbmock/dbmock.go
@@ -145,6 +145,20 @@ func (mr *MockStoreMockRecorder) BatchUpdateWorkspaceLastUsedAt(arg0, arg1 any)
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "BatchUpdateWorkspaceLastUsedAt", reflect.TypeOf((*MockStore)(nil).BatchUpdateWorkspaceLastUsedAt), arg0, arg1)
}
+// BatchUpdateWorkspaceNextStartAt mocks base method.
+func (m *MockStore) BatchUpdateWorkspaceNextStartAt(arg0 context.Context, arg1 database.BatchUpdateWorkspaceNextStartAtParams) error {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "BatchUpdateWorkspaceNextStartAt", arg0, arg1)
+ ret0, _ := ret[0].(error)
+ return ret0
+}
+
+// BatchUpdateWorkspaceNextStartAt indicates an expected call of BatchUpdateWorkspaceNextStartAt.
+func (mr *MockStoreMockRecorder) BatchUpdateWorkspaceNextStartAt(arg0, arg1 any) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "BatchUpdateWorkspaceNextStartAt", reflect.TypeOf((*MockStore)(nil).BatchUpdateWorkspaceNextStartAt), arg0, arg1)
+}
+
// BulkMarkNotificationMessagesFailed mocks base method.
func (m *MockStore) BulkMarkNotificationMessagesFailed(arg0 context.Context, arg1 database.BulkMarkNotificationMessagesFailedParams) (int64, error) {
m.ctrl.T.Helper()
@@ -3532,6 +3546,21 @@ func (mr *MockStoreMockRecorder) GetWorkspacesAndAgentsByOwnerID(arg0, arg1 any)
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetWorkspacesAndAgentsByOwnerID", reflect.TypeOf((*MockStore)(nil).GetWorkspacesAndAgentsByOwnerID), arg0, arg1)
}
+// GetWorkspacesByTemplateID mocks base method.
+func (m *MockStore) GetWorkspacesByTemplateID(arg0 context.Context, arg1 uuid.UUID) ([]database.WorkspaceTable, error) {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "GetWorkspacesByTemplateID", arg0, arg1)
+ ret0, _ := ret[0].([]database.WorkspaceTable)
+ ret1, _ := ret[1].(error)
+ return ret0, ret1
+}
+
+// GetWorkspacesByTemplateID indicates an expected call of GetWorkspacesByTemplateID.
+func (mr *MockStoreMockRecorder) GetWorkspacesByTemplateID(arg0, arg1 any) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetWorkspacesByTemplateID", reflect.TypeOf((*MockStore)(nil).GetWorkspacesByTemplateID), arg0, arg1)
+}
+
// GetWorkspacesEligibleForTransition mocks base method.
func (m *MockStore) GetWorkspacesEligibleForTransition(arg0 context.Context, arg1 time.Time) ([]database.GetWorkspacesEligibleForTransitionRow, error) {
m.ctrl.T.Helper()
@@ -5385,6 +5414,20 @@ func (mr *MockStoreMockRecorder) UpdateWorkspaceLastUsedAt(arg0, arg1 any) *gomo
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateWorkspaceLastUsedAt", reflect.TypeOf((*MockStore)(nil).UpdateWorkspaceLastUsedAt), arg0, arg1)
}
+// UpdateWorkspaceNextStartAt mocks base method.
+func (m *MockStore) UpdateWorkspaceNextStartAt(arg0 context.Context, arg1 database.UpdateWorkspaceNextStartAtParams) error {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "UpdateWorkspaceNextStartAt", arg0, arg1)
+ ret0, _ := ret[0].(error)
+ return ret0
+}
+
+// UpdateWorkspaceNextStartAt indicates an expected call of UpdateWorkspaceNextStartAt.
+func (mr *MockStoreMockRecorder) UpdateWorkspaceNextStartAt(arg0, arg1 any) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateWorkspaceNextStartAt", reflect.TypeOf((*MockStore)(nil).UpdateWorkspaceNextStartAt), arg0, arg1)
+}
+
// UpdateWorkspaceProxy mocks base method.
func (m *MockStore) UpdateWorkspaceProxy(arg0 context.Context, arg1 database.UpdateWorkspaceProxyParams) (database.WorkspaceProxy, error) {
m.ctrl.T.Helper()
diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql
index eba9b7cf106d3..782bc4969d799 100644
--- a/coderd/database/dump.sql
+++ b/coderd/database/dump.sql
@@ -380,6 +380,25 @@ BEGIN
END;
$$;
+CREATE FUNCTION nullify_next_start_at_on_workspace_autostart_modification() RETURNS trigger
+ LANGUAGE plpgsql
+ AS $$
+DECLARE
+BEGIN
+ -- A workspace's next_start_at might be invalidated by the following:
+ -- * The autostart schedule has changed independent to next_start_at
+ -- * The workspace has been marked as dormant
+ IF (NEW.autostart_schedule <> OLD.autostart_schedule AND NEW.next_start_at = OLD.next_start_at)
+ OR (NEW.dormant_at IS NOT NULL AND NEW.next_start_at IS NOT NULL)
+ THEN
+ UPDATE workspaces
+ SET next_start_at = NULL
+ WHERE id = NEW.id;
+ END IF;
+ RETURN NEW;
+END;
+$$;
+
CREATE FUNCTION provisioner_tagset_contains(provisioner_tags tagset, job_tags tagset) RETURNS boolean
LANGUAGE plpgsql
AS $$
@@ -1731,7 +1750,8 @@ CREATE TABLE workspaces (
dormant_at timestamp with time zone,
deleting_at timestamp with time zone,
automatic_updates automatic_updates DEFAULT 'never'::automatic_updates NOT NULL,
- favorite boolean DEFAULT false NOT NULL
+ favorite boolean DEFAULT false NOT NULL,
+ next_start_at timestamp with time zone
);
COMMENT ON COLUMN workspaces.favorite IS 'Favorite is true if the workspace owner has favorited the workspace.';
@@ -1752,6 +1772,7 @@ CREATE VIEW workspaces_expanded AS
workspaces.deleting_at,
workspaces.automatic_updates,
workspaces.favorite,
+ workspaces.next_start_at,
visible_users.avatar_url AS owner_avatar_url,
visible_users.username AS owner_username,
organizations.name AS organization_name,
@@ -2110,10 +2131,14 @@ CREATE INDEX workspace_app_stats_workspace_id_idx ON workspace_app_stats USING b
CREATE INDEX workspace_modules_created_at_idx ON workspace_modules USING btree (created_at);
+CREATE INDEX workspace_next_start_at_idx ON workspaces USING btree (next_start_at) WHERE (deleted = false);
+
CREATE UNIQUE INDEX workspace_proxies_lower_name_idx ON workspace_proxies USING btree (lower(name)) WHERE (deleted = false);
CREATE INDEX workspace_resources_job_id_idx ON workspace_resources USING btree (job_id);
+CREATE INDEX workspace_template_id_idx ON workspaces USING btree (template_id) WHERE (deleted = false);
+
CREATE UNIQUE INDEX workspaces_owner_id_lower_idx ON workspaces USING btree (owner_id, lower((name)::text)) WHERE (deleted = false);
CREATE OR REPLACE VIEW provisioner_job_stats AS
@@ -2192,6 +2217,8 @@ CREATE TRIGGER trigger_delete_oauth2_provider_app_token AFTER DELETE ON oauth2_p
CREATE TRIGGER trigger_insert_apikeys BEFORE INSERT ON api_keys FOR EACH ROW EXECUTE FUNCTION insert_apikey_fail_if_user_deleted();
+CREATE TRIGGER trigger_nullify_next_start_at_on_workspace_autostart_modificati AFTER UPDATE ON workspaces FOR EACH ROW EXECUTE FUNCTION nullify_next_start_at_on_workspace_autostart_modification();
+
CREATE TRIGGER trigger_update_users AFTER INSERT OR UPDATE ON users FOR EACH ROW WHEN ((new.deleted = true)) EXECUTE FUNCTION delete_deleted_user_resources();
CREATE TRIGGER trigger_upsert_user_links BEFORE INSERT OR UPDATE ON user_links FOR EACH ROW EXECUTE FUNCTION insert_user_links_fail_if_user_deleted();
diff --git a/coderd/database/migrations/000278_workspace_next_start_at.down.sql b/coderd/database/migrations/000278_workspace_next_start_at.down.sql
new file mode 100644
index 0000000000000..f47b190b59763
--- /dev/null
+++ b/coderd/database/migrations/000278_workspace_next_start_at.down.sql
@@ -0,0 +1,46 @@
+DROP VIEW workspaces_expanded;
+
+DROP TRIGGER IF EXISTS trigger_nullify_next_start_at_on_template_autostart_modification ON templates;
+DROP FUNCTION IF EXISTS nullify_next_start_at_on_template_autostart_modification;
+
+DROP TRIGGER IF EXISTS trigger_nullify_next_start_at_on_workspace_autostart_modification ON workspaces;
+DROP FUNCTION IF EXISTS nullify_next_start_at_on_workspace_autostart_modification;
+
+DROP INDEX workspace_template_id_idx;
+DROP INDEX workspace_next_start_at_idx;
+
+ALTER TABLE ONLY workspaces DROP COLUMN IF EXISTS next_start_at;
+
+CREATE VIEW
+ workspaces_expanded
+AS
+SELECT
+ workspaces.*,
+ -- Owner
+ visible_users.avatar_url AS owner_avatar_url,
+ visible_users.username AS owner_username,
+ -- Organization
+ organizations.name AS organization_name,
+ organizations.display_name AS organization_display_name,
+ organizations.icon AS organization_icon,
+ organizations.description AS organization_description,
+ -- Template
+ templates.name AS template_name,
+ templates.display_name AS template_display_name,
+ templates.icon AS template_icon,
+ templates.description AS template_description
+FROM
+ workspaces
+ INNER JOIN
+ visible_users
+ ON
+ workspaces.owner_id = visible_users.id
+ INNER JOIN
+ organizations
+ ON workspaces.organization_id = organizations.id
+ INNER JOIN
+ templates
+ ON workspaces.template_id = templates.id
+;
+
+COMMENT ON VIEW workspaces_expanded IS 'Joins in the display name information such as username, avatar, and organization name.';
diff --git a/coderd/database/migrations/000278_workspace_next_start_at.up.sql b/coderd/database/migrations/000278_workspace_next_start_at.up.sql
new file mode 100644
index 0000000000000..81240d6e08451
--- /dev/null
+++ b/coderd/database/migrations/000278_workspace_next_start_at.up.sql
@@ -0,0 +1,65 @@
+ALTER TABLE ONLY workspaces ADD COLUMN IF NOT EXISTS next_start_at TIMESTAMPTZ DEFAULT NULL;
+
+CREATE INDEX workspace_next_start_at_idx ON workspaces USING btree (next_start_at) WHERE (deleted=false);
+CREATE INDEX workspace_template_id_idx ON workspaces USING btree (template_id) WHERE (deleted=false);
+
+CREATE FUNCTION nullify_next_start_at_on_workspace_autostart_modification() RETURNS trigger
+ LANGUAGE plpgsql
+AS $$
+DECLARE
+BEGIN
+ -- A workspace's next_start_at might be invalidated by the following:
+ -- * The autostart schedule has changed independent to next_start_at
+ -- * The workspace has been marked as dormant
+ IF (NEW.autostart_schedule <> OLD.autostart_schedule AND NEW.next_start_at = OLD.next_start_at)
+ OR (NEW.dormant_at IS NOT NULL AND NEW.next_start_at IS NOT NULL)
+ THEN
+ UPDATE workspaces
+ SET next_start_at = NULL
+ WHERE id = NEW.id;
+ END IF;
+ RETURN NEW;
+END;
+$$;
+
+CREATE TRIGGER trigger_nullify_next_start_at_on_workspace_autostart_modification
+ AFTER UPDATE ON workspaces
+ FOR EACH ROW
+EXECUTE PROCEDURE nullify_next_start_at_on_workspace_autostart_modification();
+
+-- Recreate view
+DROP VIEW workspaces_expanded;
+
+CREATE VIEW
+ workspaces_expanded
+AS
+SELECT
+ workspaces.*,
+ -- Owner
+ visible_users.avatar_url AS owner_avatar_url,
+ visible_users.username AS owner_username,
+ -- Organization
+ organizations.name AS organization_name,
+ organizations.display_name AS organization_display_name,
+ organizations.icon AS organization_icon,
+ organizations.description AS organization_description,
+ -- Template
+ templates.name AS template_name,
+ templates.display_name AS template_display_name,
+ templates.icon AS template_icon,
+ templates.description AS template_description
+FROM
+ workspaces
+ INNER JOIN
+ visible_users
+ ON
+ workspaces.owner_id = visible_users.id
+ INNER JOIN
+ organizations
+ ON workspaces.organization_id = organizations.id
+ INNER JOIN
+ templates
+ ON workspaces.template_id = templates.id
+;
+
+COMMENT ON VIEW workspaces_expanded IS 'Joins in the display name information such as username, avatar, and organization name.';
diff --git a/coderd/database/modelmethods.go b/coderd/database/modelmethods.go
index a74ddf29bfcf9..002c48a9b4f81 100644
--- a/coderd/database/modelmethods.go
+++ b/coderd/database/modelmethods.go
@@ -214,6 +214,7 @@ func (w Workspace) WorkspaceTable() WorkspaceTable {
DeletingAt: w.DeletingAt,
AutomaticUpdates: w.AutomaticUpdates,
Favorite: w.Favorite,
+ NextStartAt: w.NextStartAt,
}
}
@@ -438,6 +439,7 @@ func ConvertWorkspaceRows(rows []GetWorkspacesRow) []Workspace {
TemplateDisplayName: r.TemplateDisplayName,
TemplateIcon: r.TemplateIcon,
TemplateDescription: r.TemplateDescription,
+ NextStartAt: r.NextStartAt,
}
}
diff --git a/coderd/database/modelqueries.go b/coderd/database/modelqueries.go
index ff77012755fa2..2a61f339398f2 100644
--- a/coderd/database/modelqueries.go
+++ b/coderd/database/modelqueries.go
@@ -290,6 +290,7 @@ func (q *sqlQuerier) GetAuthorizedWorkspaces(ctx context.Context, arg GetWorkspa
&i.DeletingAt,
&i.AutomaticUpdates,
&i.Favorite,
+ &i.NextStartAt,
&i.OwnerAvatarUrl,
&i.OwnerUsername,
&i.OrganizationName,
diff --git a/coderd/database/models.go b/coderd/database/models.go
index 6b99245079950..e5ddebcbc8b9a 100644
--- a/coderd/database/models.go
+++ b/coderd/database/models.go
@@ -2922,6 +2922,7 @@ type Workspace struct {
DeletingAt sql.NullTime `db:"deleting_at" json:"deleting_at"`
AutomaticUpdates AutomaticUpdates `db:"automatic_updates" json:"automatic_updates"`
Favorite bool `db:"favorite" json:"favorite"`
+ NextStartAt sql.NullTime `db:"next_start_at" json:"next_start_at"`
OwnerAvatarUrl string `db:"owner_avatar_url" json:"owner_avatar_url"`
OwnerUsername string `db:"owner_username" json:"owner_username"`
OrganizationName string `db:"organization_name" json:"organization_name"`
@@ -3225,5 +3226,6 @@ type WorkspaceTable struct {
DeletingAt sql.NullTime `db:"deleting_at" json:"deleting_at"`
AutomaticUpdates AutomaticUpdates `db:"automatic_updates" json:"automatic_updates"`
// Favorite is true if the workspace owner has favorited the workspace.
- Favorite bool `db:"favorite" json:"favorite"`
+ Favorite bool `db:"favorite" json:"favorite"`
+ NextStartAt sql.NullTime `db:"next_start_at" json:"next_start_at"`
}
diff --git a/coderd/database/querier.go b/coderd/database/querier.go
index d75b051cac330..d4eaf826d2af5 100644
--- a/coderd/database/querier.go
+++ b/coderd/database/querier.go
@@ -57,6 +57,7 @@ type sqlcQuerier interface {
// referenced by the latest build of a workspace.
ArchiveUnusedTemplateVersions(ctx context.Context, arg ArchiveUnusedTemplateVersionsParams) ([]uuid.UUID, error)
BatchUpdateWorkspaceLastUsedAt(ctx context.Context, arg BatchUpdateWorkspaceLastUsedAtParams) error
+ BatchUpdateWorkspaceNextStartAt(ctx context.Context, arg BatchUpdateWorkspaceNextStartAtParams) error
BulkMarkNotificationMessagesFailed(ctx context.Context, arg BulkMarkNotificationMessagesFailedParams) (int64, error)
BulkMarkNotificationMessagesSent(ctx context.Context, arg BulkMarkNotificationMessagesSentParams) (int64, error)
CleanTailnetCoordinators(ctx context.Context) error
@@ -348,6 +349,7 @@ type sqlcQuerier interface {
// be used in a WHERE clause.
GetWorkspaces(ctx context.Context, arg GetWorkspacesParams) ([]GetWorkspacesRow, error)
GetWorkspacesAndAgentsByOwnerID(ctx context.Context, ownerID uuid.UUID) ([]GetWorkspacesAndAgentsByOwnerIDRow, error)
+ GetWorkspacesByTemplateID(ctx context.Context, templateID uuid.UUID) ([]WorkspaceTable, error)
GetWorkspacesEligibleForTransition(ctx context.Context, now time.Time) ([]GetWorkspacesEligibleForTransitionRow, error)
InsertAPIKey(ctx context.Context, arg InsertAPIKeyParams) (APIKey, error)
// We use the organization_id as the id
@@ -496,6 +498,7 @@ type sqlcQuerier interface {
UpdateWorkspaceDeletedByID(ctx context.Context, arg UpdateWorkspaceDeletedByIDParams) error
UpdateWorkspaceDormantDeletingAt(ctx context.Context, arg UpdateWorkspaceDormantDeletingAtParams) (WorkspaceTable, error)
UpdateWorkspaceLastUsedAt(ctx context.Context, arg UpdateWorkspaceLastUsedAtParams) error
+ UpdateWorkspaceNextStartAt(ctx context.Context, arg UpdateWorkspaceNextStartAtParams) error
// This allows editing the properties of a workspace proxy.
UpdateWorkspaceProxy(ctx context.Context, arg UpdateWorkspaceProxyParams) (WorkspaceProxy, error)
UpdateWorkspaceProxyDeleted(ctx context.Context, arg UpdateWorkspaceProxyDeletedParams) error
diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go
index 33a3ce12a444d..4a97519a99f45 100644
--- a/coderd/database/queries.sql.go
+++ b/coderd/database/queries.sql.go
@@ -11228,7 +11228,7 @@ func (q *sqlQuerier) DeleteOldWorkspaceAgentLogs(ctx context.Context, threshold
const getWorkspaceAgentAndLatestBuildByAuthToken = `-- name: GetWorkspaceAgentAndLatestBuildByAuthToken :one
SELECT
- workspaces.id, workspaces.created_at, workspaces.updated_at, workspaces.owner_id, workspaces.organization_id, workspaces.template_id, workspaces.deleted, workspaces.name, workspaces.autostart_schedule, workspaces.ttl, workspaces.last_used_at, workspaces.dormant_at, workspaces.deleting_at, workspaces.automatic_updates, workspaces.favorite,
+ workspaces.id, workspaces.created_at, workspaces.updated_at, workspaces.owner_id, workspaces.organization_id, workspaces.template_id, workspaces.deleted, workspaces.name, workspaces.autostart_schedule, workspaces.ttl, workspaces.last_used_at, workspaces.dormant_at, workspaces.deleting_at, workspaces.automatic_updates, workspaces.favorite, workspaces.next_start_at,
workspace_agents.id, workspace_agents.created_at, workspace_agents.updated_at, workspace_agents.name, workspace_agents.first_connected_at, workspace_agents.last_connected_at, workspace_agents.disconnected_at, workspace_agents.resource_id, workspace_agents.auth_token, workspace_agents.auth_instance_id, workspace_agents.architecture, workspace_agents.environment_variables, workspace_agents.operating_system, workspace_agents.instance_metadata, workspace_agents.resource_metadata, workspace_agents.directory, workspace_agents.version, workspace_agents.last_connected_replica_id, workspace_agents.connection_timeout_seconds, workspace_agents.troubleshooting_url, workspace_agents.motd_file, workspace_agents.lifecycle_state, workspace_agents.expanded_directory, workspace_agents.logs_length, workspace_agents.logs_overflowed, workspace_agents.started_at, workspace_agents.ready_at, workspace_agents.subsystems, workspace_agents.display_apps, workspace_agents.api_version, workspace_agents.display_order,
workspace_build_with_user.id, workspace_build_with_user.created_at, workspace_build_with_user.updated_at, workspace_build_with_user.workspace_id, workspace_build_with_user.template_version_id, workspace_build_with_user.build_number, workspace_build_with_user.transition, workspace_build_with_user.initiator_id, workspace_build_with_user.provisioner_state, workspace_build_with_user.job_id, workspace_build_with_user.deadline, workspace_build_with_user.reason, workspace_build_with_user.daily_cost, workspace_build_with_user.max_deadline, workspace_build_with_user.initiator_by_avatar_url, workspace_build_with_user.initiator_by_username
FROM
@@ -11287,6 +11287,7 @@ func (q *sqlQuerier) GetWorkspaceAgentAndLatestBuildByAuthToken(ctx context.Cont
&i.WorkspaceTable.DeletingAt,
&i.WorkspaceTable.AutomaticUpdates,
&i.WorkspaceTable.Favorite,
+ &i.WorkspaceTable.NextStartAt,
&i.WorkspaceAgent.ID,
&i.WorkspaceAgent.CreatedAt,
&i.WorkspaceAgent.UpdatedAt,
@@ -14720,6 +14721,33 @@ func (q *sqlQuerier) BatchUpdateWorkspaceLastUsedAt(ctx context.Context, arg Bat
return err
}
+const batchUpdateWorkspaceNextStartAt = `-- name: BatchUpdateWorkspaceNextStartAt :exec
+UPDATE
+ workspaces
+SET
+ next_start_at = CASE
+ WHEN batch.next_start_at = '0001-01-01 00:00:00+00'::timestamptz THEN NULL
+ ELSE batch.next_start_at
+ END
+FROM (
+ SELECT
+ unnest($1::uuid[]) AS id,
+ unnest($2::timestamptz[]) AS next_start_at
+) AS batch
+WHERE
+ workspaces.id = batch.id
+`
+
+type BatchUpdateWorkspaceNextStartAtParams struct {
+ IDs []uuid.UUID `db:"ids" json:"ids"`
+ NextStartAts []time.Time `db:"next_start_ats" json:"next_start_ats"`
+}
+
+func (q *sqlQuerier) BatchUpdateWorkspaceNextStartAt(ctx context.Context, arg BatchUpdateWorkspaceNextStartAtParams) error {
+ _, err := q.db.ExecContext(ctx, batchUpdateWorkspaceNextStartAt, pq.Array(arg.IDs), pq.Array(arg.NextStartAts))
+ return err
+}
+
const favoriteWorkspace = `-- name: FavoriteWorkspace :exec
UPDATE workspaces SET favorite = true WHERE id = $1
`
@@ -14815,7 +14843,7 @@ func (q *sqlQuerier) GetDeploymentWorkspaceStats(ctx context.Context) (GetDeploy
const getWorkspaceByAgentID = `-- name: GetWorkspaceByAgentID :one
SELECT
- id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, ttl, last_used_at, dormant_at, deleting_at, automatic_updates, favorite, owner_avatar_url, owner_username, organization_name, organization_display_name, organization_icon, organization_description, template_name, template_display_name, template_icon, template_description
+ id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, ttl, last_used_at, dormant_at, deleting_at, automatic_updates, favorite, next_start_at, owner_avatar_url, owner_username, organization_name, organization_display_name, organization_icon, organization_description, template_name, template_display_name, template_icon, template_description
FROM
workspaces_expanded as workspaces
WHERE
@@ -14862,6 +14890,7 @@ func (q *sqlQuerier) GetWorkspaceByAgentID(ctx context.Context, agentID uuid.UUI
&i.DeletingAt,
&i.AutomaticUpdates,
&i.Favorite,
+ &i.NextStartAt,
&i.OwnerAvatarUrl,
&i.OwnerUsername,
&i.OrganizationName,
@@ -14878,7 +14907,7 @@ func (q *sqlQuerier) GetWorkspaceByAgentID(ctx context.Context, agentID uuid.UUI
const getWorkspaceByID = `-- name: GetWorkspaceByID :one
SELECT
- id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, ttl, last_used_at, dormant_at, deleting_at, automatic_updates, favorite, owner_avatar_url, owner_username, organization_name, organization_display_name, organization_icon, organization_description, template_name, template_display_name, template_icon, template_description
+ id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, ttl, last_used_at, dormant_at, deleting_at, automatic_updates, favorite, next_start_at, owner_avatar_url, owner_username, organization_name, organization_display_name, organization_icon, organization_description, template_name, template_display_name, template_icon, template_description
FROM
workspaces_expanded
WHERE
@@ -14906,6 +14935,7 @@ func (q *sqlQuerier) GetWorkspaceByID(ctx context.Context, id uuid.UUID) (Worksp
&i.DeletingAt,
&i.AutomaticUpdates,
&i.Favorite,
+ &i.NextStartAt,
&i.OwnerAvatarUrl,
&i.OwnerUsername,
&i.OrganizationName,
@@ -14922,7 +14952,7 @@ func (q *sqlQuerier) GetWorkspaceByID(ctx context.Context, id uuid.UUID) (Worksp
const getWorkspaceByOwnerIDAndName = `-- name: GetWorkspaceByOwnerIDAndName :one
SELECT
- id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, ttl, last_used_at, dormant_at, deleting_at, automatic_updates, favorite, owner_avatar_url, owner_username, organization_name, organization_display_name, organization_icon, organization_description, template_name, template_display_name, template_icon, template_description
+ id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, ttl, last_used_at, dormant_at, deleting_at, automatic_updates, favorite, next_start_at, owner_avatar_url, owner_username, organization_name, organization_display_name, organization_icon, organization_description, template_name, template_display_name, template_icon, template_description
FROM
workspaces_expanded as workspaces
WHERE
@@ -14957,6 +14987,7 @@ func (q *sqlQuerier) GetWorkspaceByOwnerIDAndName(ctx context.Context, arg GetWo
&i.DeletingAt,
&i.AutomaticUpdates,
&i.Favorite,
+ &i.NextStartAt,
&i.OwnerAvatarUrl,
&i.OwnerUsername,
&i.OrganizationName,
@@ -14973,7 +15004,7 @@ func (q *sqlQuerier) GetWorkspaceByOwnerIDAndName(ctx context.Context, arg GetWo
const getWorkspaceByWorkspaceAppID = `-- name: GetWorkspaceByWorkspaceAppID :one
SELECT
- id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, ttl, last_used_at, dormant_at, deleting_at, automatic_updates, favorite, owner_avatar_url, owner_username, organization_name, organization_display_name, organization_icon, organization_description, template_name, template_display_name, template_icon, template_description
+ id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, ttl, last_used_at, dormant_at, deleting_at, automatic_updates, favorite, next_start_at, owner_avatar_url, owner_username, organization_name, organization_display_name, organization_icon, organization_description, template_name, template_display_name, template_icon, template_description
FROM
workspaces_expanded as workspaces
WHERE
@@ -15027,6 +15058,7 @@ func (q *sqlQuerier) GetWorkspaceByWorkspaceAppID(ctx context.Context, workspace
&i.DeletingAt,
&i.AutomaticUpdates,
&i.Favorite,
+ &i.NextStartAt,
&i.OwnerAvatarUrl,
&i.OwnerUsername,
&i.OrganizationName,
@@ -15088,7 +15120,7 @@ SELECT
),
filtered_workspaces AS (
SELECT
- workspaces.id, workspaces.created_at, workspaces.updated_at, workspaces.owner_id, workspaces.organization_id, workspaces.template_id, workspaces.deleted, workspaces.name, workspaces.autostart_schedule, workspaces.ttl, workspaces.last_used_at, workspaces.dormant_at, workspaces.deleting_at, workspaces.automatic_updates, workspaces.favorite, workspaces.owner_avatar_url, workspaces.owner_username, workspaces.organization_name, workspaces.organization_display_name, workspaces.organization_icon, workspaces.organization_description, workspaces.template_name, workspaces.template_display_name, workspaces.template_icon, workspaces.template_description,
+ workspaces.id, workspaces.created_at, workspaces.updated_at, workspaces.owner_id, workspaces.organization_id, workspaces.template_id, workspaces.deleted, workspaces.name, workspaces.autostart_schedule, workspaces.ttl, workspaces.last_used_at, workspaces.dormant_at, workspaces.deleting_at, workspaces.automatic_updates, workspaces.favorite, workspaces.next_start_at, workspaces.owner_avatar_url, workspaces.owner_username, workspaces.organization_name, workspaces.organization_display_name, workspaces.organization_icon, workspaces.organization_description, workspaces.template_name, workspaces.template_display_name, workspaces.template_icon, workspaces.template_description,
latest_build.template_version_id,
latest_build.template_version_name,
latest_build.completed_at as latest_build_completed_at,
@@ -15328,7 +15360,7 @@ WHERE
-- @authorize_filter
), filtered_workspaces_order AS (
SELECT
- fw.id, fw.created_at, fw.updated_at, fw.owner_id, fw.organization_id, fw.template_id, fw.deleted, fw.name, fw.autostart_schedule, fw.ttl, fw.last_used_at, fw.dormant_at, fw.deleting_at, fw.automatic_updates, fw.favorite, fw.owner_avatar_url, fw.owner_username, fw.organization_name, fw.organization_display_name, fw.organization_icon, fw.organization_description, fw.template_name, fw.template_display_name, fw.template_icon, fw.template_description, fw.template_version_id, fw.template_version_name, fw.latest_build_completed_at, fw.latest_build_canceled_at, fw.latest_build_error, fw.latest_build_transition, fw.latest_build_status
+ fw.id, fw.created_at, fw.updated_at, fw.owner_id, fw.organization_id, fw.template_id, fw.deleted, fw.name, fw.autostart_schedule, fw.ttl, fw.last_used_at, fw.dormant_at, fw.deleting_at, fw.automatic_updates, fw.favorite, fw.next_start_at, fw.owner_avatar_url, fw.owner_username, fw.organization_name, fw.organization_display_name, fw.organization_icon, fw.organization_description, fw.template_name, fw.template_display_name, fw.template_icon, fw.template_description, fw.template_version_id, fw.template_version_name, fw.latest_build_completed_at, fw.latest_build_canceled_at, fw.latest_build_error, fw.latest_build_transition, fw.latest_build_status
FROM
filtered_workspaces fw
ORDER BY
@@ -15349,7 +15381,7 @@ WHERE
$20
), filtered_workspaces_order_with_summary AS (
SELECT
- fwo.id, fwo.created_at, fwo.updated_at, fwo.owner_id, fwo.organization_id, fwo.template_id, fwo.deleted, fwo.name, fwo.autostart_schedule, fwo.ttl, fwo.last_used_at, fwo.dormant_at, fwo.deleting_at, fwo.automatic_updates, fwo.favorite, fwo.owner_avatar_url, fwo.owner_username, fwo.organization_name, fwo.organization_display_name, fwo.organization_icon, fwo.organization_description, fwo.template_name, fwo.template_display_name, fwo.template_icon, fwo.template_description, fwo.template_version_id, fwo.template_version_name, fwo.latest_build_completed_at, fwo.latest_build_canceled_at, fwo.latest_build_error, fwo.latest_build_transition, fwo.latest_build_status
+ fwo.id, fwo.created_at, fwo.updated_at, fwo.owner_id, fwo.organization_id, fwo.template_id, fwo.deleted, fwo.name, fwo.autostart_schedule, fwo.ttl, fwo.last_used_at, fwo.dormant_at, fwo.deleting_at, fwo.automatic_updates, fwo.favorite, fwo.next_start_at, fwo.owner_avatar_url, fwo.owner_username, fwo.organization_name, fwo.organization_display_name, fwo.organization_icon, fwo.organization_description, fwo.template_name, fwo.template_display_name, fwo.template_icon, fwo.template_description, fwo.template_version_id, fwo.template_version_name, fwo.latest_build_completed_at, fwo.latest_build_canceled_at, fwo.latest_build_error, fwo.latest_build_transition, fwo.latest_build_status
FROM
filtered_workspaces_order fwo
-- Return a technical summary row with total count of workspaces.
@@ -15371,6 +15403,7 @@ WHERE
'0001-01-01 00:00:00+00'::timestamptz, -- deleting_at
'never'::automatic_updates, -- automatic_updates
false, -- favorite
+ '0001-01-01 00:00:00+00'::timestamptz, -- next_start_at
'', -- owner_avatar_url
'', -- owner_username
'', -- organization_name
@@ -15398,7 +15431,7 @@ WHERE
filtered_workspaces
)
SELECT
- fwos.id, fwos.created_at, fwos.updated_at, fwos.owner_id, fwos.organization_id, fwos.template_id, fwos.deleted, fwos.name, fwos.autostart_schedule, fwos.ttl, fwos.last_used_at, fwos.dormant_at, fwos.deleting_at, fwos.automatic_updates, fwos.favorite, fwos.owner_avatar_url, fwos.owner_username, fwos.organization_name, fwos.organization_display_name, fwos.organization_icon, fwos.organization_description, fwos.template_name, fwos.template_display_name, fwos.template_icon, fwos.template_description, fwos.template_version_id, fwos.template_version_name, fwos.latest_build_completed_at, fwos.latest_build_canceled_at, fwos.latest_build_error, fwos.latest_build_transition, fwos.latest_build_status,
+ fwos.id, fwos.created_at, fwos.updated_at, fwos.owner_id, fwos.organization_id, fwos.template_id, fwos.deleted, fwos.name, fwos.autostart_schedule, fwos.ttl, fwos.last_used_at, fwos.dormant_at, fwos.deleting_at, fwos.automatic_updates, fwos.favorite, fwos.next_start_at, fwos.owner_avatar_url, fwos.owner_username, fwos.organization_name, fwos.organization_display_name, fwos.organization_icon, fwos.organization_description, fwos.template_name, fwos.template_display_name, fwos.template_icon, fwos.template_description, fwos.template_version_id, fwos.template_version_name, fwos.latest_build_completed_at, fwos.latest_build_canceled_at, fwos.latest_build_error, fwos.latest_build_transition, fwos.latest_build_status,
tc.count
FROM
filtered_workspaces_order_with_summary fwos
@@ -15447,6 +15480,7 @@ type GetWorkspacesRow struct {
DeletingAt sql.NullTime `db:"deleting_at" json:"deleting_at"`
AutomaticUpdates AutomaticUpdates `db:"automatic_updates" json:"automatic_updates"`
Favorite bool `db:"favorite" json:"favorite"`
+ NextStartAt sql.NullTime `db:"next_start_at" json:"next_start_at"`
OwnerAvatarUrl string `db:"owner_avatar_url" json:"owner_avatar_url"`
OwnerUsername string `db:"owner_username" json:"owner_username"`
OrganizationName string `db:"organization_name" json:"organization_name"`
@@ -15518,6 +15552,7 @@ func (q *sqlQuerier) GetWorkspaces(ctx context.Context, arg GetWorkspacesParams)
&i.DeletingAt,
&i.AutomaticUpdates,
&i.Favorite,
+ &i.NextStartAt,
&i.OwnerAvatarUrl,
&i.OwnerUsername,
&i.OrganizationName,
@@ -15625,6 +15660,50 @@ func (q *sqlQuerier) GetWorkspacesAndAgentsByOwnerID(ctx context.Context, ownerI
return items, nil
}
+const getWorkspacesByTemplateID = `-- name: GetWorkspacesByTemplateID :many
+SELECT id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, ttl, last_used_at, dormant_at, deleting_at, automatic_updates, favorite, next_start_at FROM workspaces WHERE template_id = $1 AND deleted = false
+`
+
+func (q *sqlQuerier) GetWorkspacesByTemplateID(ctx context.Context, templateID uuid.UUID) ([]WorkspaceTable, error) {
+ rows, err := q.db.QueryContext(ctx, getWorkspacesByTemplateID, templateID)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var items []WorkspaceTable
+ for rows.Next() {
+ var i WorkspaceTable
+ if err := rows.Scan(
+ &i.ID,
+ &i.CreatedAt,
+ &i.UpdatedAt,
+ &i.OwnerID,
+ &i.OrganizationID,
+ &i.TemplateID,
+ &i.Deleted,
+ &i.Name,
+ &i.AutostartSchedule,
+ &i.Ttl,
+ &i.LastUsedAt,
+ &i.DormantAt,
+ &i.DeletingAt,
+ &i.AutomaticUpdates,
+ &i.Favorite,
+ &i.NextStartAt,
+ ); 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 getWorkspacesEligibleForTransition = `-- name: GetWorkspacesEligibleForTransition :many
SELECT
workspaces.id,
@@ -15670,12 +15749,25 @@ WHERE
-- * The workspace's owner is active.
-- * The provisioner job did not fail.
-- * The workspace build was a stop transition.
+ -- * The workspace is not dormant
-- * The workspace has an autostart schedule.
+ -- * It is after the workspace's next start time.
(
users.status = 'active'::user_status AND
provisioner_jobs.job_status != 'failed'::provisioner_job_status AND
workspace_builds.transition = 'stop'::workspace_transition AND
- workspaces.autostart_schedule IS NOT NULL
+ workspaces.dormant_at IS NULL AND
+ workspaces.autostart_schedule IS NOT NULL AND
+ (
+ -- next_start_at might be null in these two scenarios:
+ -- * A coder instance was updated and we haven't updated next_start_at yet.
+ -- * A database trigger made it null because of an update to a related column.
+ --
+ -- When this occurs, we return the workspace so the Coder server can
+ -- compute a valid next start at and update it.
+ workspaces.next_start_at IS NULL OR
+ workspaces.next_start_at <= $1 :: timestamptz
+ )
) OR
-- A workspace may be eligible for dormant stop if the following are true:
@@ -15774,10 +15866,11 @@ INSERT INTO
autostart_schedule,
ttl,
last_used_at,
- automatic_updates
+ automatic_updates,
+ next_start_at
)
VALUES
- ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, ttl, last_used_at, dormant_at, deleting_at, automatic_updates, favorite
+ ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) RETURNING id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, ttl, last_used_at, dormant_at, deleting_at, automatic_updates, favorite, next_start_at
`
type InsertWorkspaceParams struct {
@@ -15792,6 +15885,7 @@ type InsertWorkspaceParams struct {
Ttl sql.NullInt64 `db:"ttl" json:"ttl"`
LastUsedAt time.Time `db:"last_used_at" json:"last_used_at"`
AutomaticUpdates AutomaticUpdates `db:"automatic_updates" json:"automatic_updates"`
+ NextStartAt sql.NullTime `db:"next_start_at" json:"next_start_at"`
}
func (q *sqlQuerier) InsertWorkspace(ctx context.Context, arg InsertWorkspaceParams) (WorkspaceTable, error) {
@@ -15807,6 +15901,7 @@ func (q *sqlQuerier) InsertWorkspace(ctx context.Context, arg InsertWorkspacePar
arg.Ttl,
arg.LastUsedAt,
arg.AutomaticUpdates,
+ arg.NextStartAt,
)
var i WorkspaceTable
err := row.Scan(
@@ -15825,6 +15920,7 @@ func (q *sqlQuerier) InsertWorkspace(ctx context.Context, arg InsertWorkspacePar
&i.DeletingAt,
&i.AutomaticUpdates,
&i.Favorite,
+ &i.NextStartAt,
)
return i, err
}
@@ -15864,7 +15960,7 @@ SET
WHERE
id = $1
AND deleted = false
-RETURNING id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, ttl, last_used_at, dormant_at, deleting_at, automatic_updates, favorite
+RETURNING id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, ttl, last_used_at, dormant_at, deleting_at, automatic_updates, favorite, next_start_at
`
type UpdateWorkspaceParams struct {
@@ -15891,6 +15987,7 @@ func (q *sqlQuerier) UpdateWorkspace(ctx context.Context, arg UpdateWorkspacePar
&i.DeletingAt,
&i.AutomaticUpdates,
&i.Favorite,
+ &i.NextStartAt,
)
return i, err
}
@@ -15918,7 +16015,8 @@ const updateWorkspaceAutostart = `-- name: UpdateWorkspaceAutostart :exec
UPDATE
workspaces
SET
- autostart_schedule = $2
+ autostart_schedule = $2,
+ next_start_at = $3
WHERE
id = $1
`
@@ -15926,10 +16024,11 @@ WHERE
type UpdateWorkspaceAutostartParams struct {
ID uuid.UUID `db:"id" json:"id"`
AutostartSchedule sql.NullString `db:"autostart_schedule" json:"autostart_schedule"`
+ NextStartAt sql.NullTime `db:"next_start_at" json:"next_start_at"`
}
func (q *sqlQuerier) UpdateWorkspaceAutostart(ctx context.Context, arg UpdateWorkspaceAutostartParams) error {
- _, err := q.db.ExecContext(ctx, updateWorkspaceAutostart, arg.ID, arg.AutostartSchedule)
+ _, err := q.db.ExecContext(ctx, updateWorkspaceAutostart, arg.ID, arg.AutostartSchedule, arg.NextStartAt)
return err
}
@@ -15977,7 +16076,7 @@ WHERE
workspaces.id = $1
AND templates.id = workspaces.template_id
RETURNING
- workspaces.id, workspaces.created_at, workspaces.updated_at, workspaces.owner_id, workspaces.organization_id, workspaces.template_id, workspaces.deleted, workspaces.name, workspaces.autostart_schedule, workspaces.ttl, workspaces.last_used_at, workspaces.dormant_at, workspaces.deleting_at, workspaces.automatic_updates, workspaces.favorite
+ workspaces.id, workspaces.created_at, workspaces.updated_at, workspaces.owner_id, workspaces.organization_id, workspaces.template_id, workspaces.deleted, workspaces.name, workspaces.autostart_schedule, workspaces.ttl, workspaces.last_used_at, workspaces.dormant_at, workspaces.deleting_at, workspaces.automatic_updates, workspaces.favorite, workspaces.next_start_at
`
type UpdateWorkspaceDormantDeletingAtParams struct {
@@ -16004,6 +16103,7 @@ func (q *sqlQuerier) UpdateWorkspaceDormantDeletingAt(ctx context.Context, arg U
&i.DeletingAt,
&i.AutomaticUpdates,
&i.Favorite,
+ &i.NextStartAt,
)
return i, err
}
@@ -16027,6 +16127,25 @@ func (q *sqlQuerier) UpdateWorkspaceLastUsedAt(ctx context.Context, arg UpdateWo
return err
}
+const updateWorkspaceNextStartAt = `-- name: UpdateWorkspaceNextStartAt :exec
+UPDATE
+ workspaces
+SET
+ next_start_at = $2
+WHERE
+ id = $1
+`
+
+type UpdateWorkspaceNextStartAtParams struct {
+ ID uuid.UUID `db:"id" json:"id"`
+ NextStartAt sql.NullTime `db:"next_start_at" json:"next_start_at"`
+}
+
+func (q *sqlQuerier) UpdateWorkspaceNextStartAt(ctx context.Context, arg UpdateWorkspaceNextStartAtParams) error {
+ _, err := q.db.ExecContext(ctx, updateWorkspaceNextStartAt, arg.ID, arg.NextStartAt)
+ return err
+}
+
const updateWorkspaceTTL = `-- name: UpdateWorkspaceTTL :exec
UPDATE
workspaces
@@ -16059,7 +16178,7 @@ WHERE
template_id = $3
AND
dormant_at IS NOT NULL
-RETURNING id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, ttl, last_used_at, dormant_at, deleting_at, automatic_updates, favorite
+RETURNING id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, ttl, last_used_at, dormant_at, deleting_at, automatic_updates, favorite, next_start_at
`
type UpdateWorkspacesDormantDeletingAtByTemplateIDParams struct {
@@ -16093,6 +16212,7 @@ func (q *sqlQuerier) UpdateWorkspacesDormantDeletingAtByTemplateID(ctx context.C
&i.DeletingAt,
&i.AutomaticUpdates,
&i.Favorite,
+ &i.NextStartAt,
); err != nil {
return nil, err
}
diff --git a/coderd/database/queries/workspaces.sql b/coderd/database/queries/workspaces.sql
index 4d200a33f1620..cdf4dfa5f0e3e 100644
--- a/coderd/database/queries/workspaces.sql
+++ b/coderd/database/queries/workspaces.sql
@@ -368,6 +368,7 @@ WHERE
'0001-01-01 00:00:00+00'::timestamptz, -- deleting_at
'never'::automatic_updates, -- automatic_updates
false, -- favorite
+ '0001-01-01 00:00:00+00'::timestamptz, -- next_start_at
'', -- owner_avatar_url
'', -- owner_username
'', -- organization_name
@@ -435,10 +436,11 @@ INSERT INTO
autostart_schedule,
ttl,
last_used_at,
- automatic_updates
+ automatic_updates,
+ next_start_at
)
VALUES
- ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING *;
+ ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) RETURNING *;
-- name: UpdateWorkspaceDeletedByID :exec
UPDATE
@@ -462,10 +464,35 @@ RETURNING *;
UPDATE
workspaces
SET
- autostart_schedule = $2
+ autostart_schedule = $2,
+ next_start_at = $3
WHERE
id = $1;
+-- name: UpdateWorkspaceNextStartAt :exec
+UPDATE
+ workspaces
+SET
+ next_start_at = $2
+WHERE
+ id = $1;
+
+-- name: BatchUpdateWorkspaceNextStartAt :exec
+UPDATE
+ workspaces
+SET
+ next_start_at = CASE
+ WHEN batch.next_start_at = '0001-01-01 00:00:00+00'::timestamptz THEN NULL
+ ELSE batch.next_start_at
+ END
+FROM (
+ SELECT
+ unnest(sqlc.arg(ids)::uuid[]) AS id,
+ unnest(sqlc.arg(next_start_ats)::timestamptz[]) AS next_start_at
+) AS batch
+WHERE
+ workspaces.id = batch.id;
+
-- name: UpdateWorkspaceTTL :exec
UPDATE
workspaces
@@ -600,12 +627,25 @@ WHERE
-- * The workspace's owner is active.
-- * The provisioner job did not fail.
-- * The workspace build was a stop transition.
+ -- * The workspace is not dormant
-- * The workspace has an autostart schedule.
+ -- * It is after the workspace's next start time.
(
users.status = 'active'::user_status AND
provisioner_jobs.job_status != 'failed'::provisioner_job_status AND
workspace_builds.transition = 'stop'::workspace_transition AND
- workspaces.autostart_schedule IS NOT NULL
+ workspaces.dormant_at IS NULL AND
+ workspaces.autostart_schedule IS NOT NULL AND
+ (
+ -- next_start_at might be null in these two scenarios:
+ -- * A coder instance was updated and we haven't updated next_start_at yet.
+ -- * A database trigger made it null because of an update to a related column.
+ --
+ -- When this occurs, we return the workspace so the Coder server can
+ -- compute a valid next start at and update it.
+ workspaces.next_start_at IS NULL OR
+ workspaces.next_start_at <= @now :: timestamptz
+ )
) OR
-- A workspace may be eligible for dormant stop if the following are true:
@@ -761,3 +801,6 @@ WHERE
-- Authorize Filter clause will be injected below in GetAuthorizedWorkspacesAndAgentsByOwnerID
-- @authorize_filter
GROUP BY workspaces.id, workspaces.name, latest_build.job_status, latest_build.job_id, latest_build.transition;
+
+-- name: GetWorkspacesByTemplateID :many
+SELECT * FROM workspaces WHERE template_id = $1 AND deleted = false;
diff --git a/coderd/provisionerdserver/provisionerdserver.go b/coderd/provisionerdserver/provisionerdserver.go
index 0e9892b892172..8899aa999f503 100644
--- a/coderd/provisionerdserver/provisionerdserver.go
+++ b/coderd/provisionerdserver/provisionerdserver.go
@@ -1438,9 +1438,11 @@ func (s *server) CompleteJob(ctx context.Context, completed *proto.CompletedJob)
return getWorkspaceError
}
+ templateScheduleStore := *s.TemplateScheduleStore.Load()
+
autoStop, err := schedule.CalculateAutostop(ctx, schedule.CalculateAutostopParams{
Database: db,
- TemplateScheduleStore: *s.TemplateScheduleStore.Load(),
+ TemplateScheduleStore: templateScheduleStore,
UserQuietHoursScheduleStore: *s.UserQuietHoursScheduleStore.Load(),
Now: now,
Workspace: workspace.WorkspaceTable(),
@@ -1451,6 +1453,24 @@ func (s *server) CompleteJob(ctx context.Context, completed *proto.CompletedJob)
return xerrors.Errorf("calculate auto stop: %w", err)
}
+ if workspace.AutostartSchedule.Valid {
+ templateScheduleOptions, err := templateScheduleStore.Get(ctx, db, workspace.TemplateID)
+ if err != nil {
+ return xerrors.Errorf("get template schedule options: %w", err)
+ }
+
+ nextStartAt, err := schedule.NextAllowedAutostart(now, workspace.AutostartSchedule.String, templateScheduleOptions)
+ if err == nil {
+ err = db.UpdateWorkspaceNextStartAt(ctx, database.UpdateWorkspaceNextStartAtParams{
+ ID: workspace.ID,
+ NextStartAt: sql.NullTime{Valid: true, Time: nextStartAt.UTC()},
+ })
+ if err != nil {
+ return xerrors.Errorf("update workspace next start at: %w", err)
+ }
+ }
+ }
+
err = db.UpdateProvisionerJobWithCompleteByID(ctx, database.UpdateProvisionerJobWithCompleteByIDParams{
ID: jobID,
UpdatedAt: now,
diff --git a/coderd/schedule/autostart.go b/coderd/schedule/autostart.go
index 681bd5cfda718..0a7f583e4f9b2 100644
--- a/coderd/schedule/autostart.go
+++ b/coderd/schedule/autostart.go
@@ -3,9 +3,13 @@ package schedule
import (
"time"
+ "golang.org/x/xerrors"
+
"github.com/coder/coder/v2/coderd/schedule/cron"
)
+var ErrNoAllowedAutostart = xerrors.New("no allowed autostart")
+
// NextAutostart takes the workspace and template schedule and returns the next autostart schedule
// after "at". The boolean returned is if the autostart should be allowed to start based on the template
// schedule.
@@ -28,3 +32,19 @@ func NextAutostart(at time.Time, wsSchedule string, templateSchedule TemplateSch
return zonedTransition, allowed
}
+
+func NextAllowedAutostart(at time.Time, wsSchedule string, templateSchedule TemplateScheduleOptions) (time.Time, error) {
+ next := at
+
+ // Our cron schedules work on a weekly basis, so to ensure we've exhausted all
+ // possible autostart times we need to check up to 7 days worth of autostarts.
+ for next.Sub(at) < 7*24*time.Hour {
+ var valid bool
+ next, valid = NextAutostart(next, wsSchedule, templateSchedule)
+ if valid {
+ return next, nil
+ }
+ }
+
+ return time.Time{}, ErrNoAllowedAutostart
+}
diff --git a/coderd/schedule/autostart_test.go b/coderd/schedule/autostart_test.go
new file mode 100644
index 0000000000000..6dacee14614d7
--- /dev/null
+++ b/coderd/schedule/autostart_test.go
@@ -0,0 +1,41 @@
+package schedule_test
+
+import (
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/require"
+
+ "github.com/coder/coder/v2/coderd/schedule"
+)
+
+func TestNextAllowedAutostart(t *testing.T) {
+ t.Parallel()
+
+ t.Run("WhenScheduleOutOfSync", func(t *testing.T) {
+ t.Parallel()
+
+ // 1st January 2024 is a Monday
+ at := time.Date(2024, time.January, 1, 10, 0, 0, 0, time.UTC)
+ // Monday-Friday 9:00AM UTC
+ sched := "CRON_TZ=UTC 00 09 * * 1-5"
+ // Only allow an autostart on mondays
+ opts := schedule.TemplateScheduleOptions{
+ AutostartRequirement: schedule.TemplateAutostartRequirement{
+ DaysOfWeek: 0b00000001,
+ },
+ }
+
+ // NextAutostart will return a non-allowed autostart time as
+ // our AutostartRequirement only allows Mondays but we expect
+ // this to return a Tuesday.
+ next, allowed := schedule.NextAutostart(at, sched, opts)
+ require.False(t, allowed)
+ require.Equal(t, time.Date(2024, time.January, 2, 9, 0, 0, 0, time.UTC), next)
+
+ // NextAllowedAutostart should return the next allowed autostart time.
+ next, err := schedule.NextAllowedAutostart(at, sched, opts)
+ require.NoError(t, err)
+ require.Equal(t, time.Date(2024, time.January, 8, 9, 0, 0, 0, time.UTC), next)
+ })
+}
diff --git a/coderd/workspaces.go b/coderd/workspaces.go
index ff8a55ded775a..4f1cd31700eca 100644
--- a/coderd/workspaces.go
+++ b/coderd/workspaces.go
@@ -29,6 +29,7 @@ import (
"github.com/coder/coder/v2/coderd/notifications"
"github.com/coder/coder/v2/coderd/rbac"
"github.com/coder/coder/v2/coderd/rbac/policy"
+ "github.com/coder/coder/v2/coderd/schedule"
"github.com/coder/coder/v2/coderd/schedule/cron"
"github.com/coder/coder/v2/coderd/searchquery"
"github.com/coder/coder/v2/coderd/telemetry"
@@ -554,6 +555,14 @@ func createWorkspace(
return
}
+ nextStartAt := sql.NullTime{}
+ if dbAutostartSchedule.Valid {
+ next, err := schedule.NextAllowedAutostart(dbtime.Now(), dbAutostartSchedule.String, templateSchedule)
+ if err == nil {
+ nextStartAt = sql.NullTime{Valid: true, Time: dbtime.Time(next.UTC())}
+ }
+ }
+
dbTTL, err := validWorkspaceTTLMillis(req.TTLMillis, templateSchedule.DefaultTTL)
if err != nil {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
@@ -618,6 +627,7 @@ func createWorkspace(
TemplateID: template.ID,
Name: req.Name,
AutostartSchedule: dbAutostartSchedule,
+ NextStartAt: nextStartAt,
Ttl: dbTTL,
// The workspaces page will sort by last used at, and it's useful to
// have the newly created workspace at the top of the list!
@@ -873,9 +883,18 @@ func (api *API) putWorkspaceAutostart(rw http.ResponseWriter, r *http.Request) {
return
}
+ nextStartAt := sql.NullTime{}
+ if dbSched.Valid {
+ next, err := schedule.NextAllowedAutostart(dbtime.Now(), dbSched.String, templateSchedule)
+ if err == nil {
+ nextStartAt = sql.NullTime{Valid: true, Time: dbtime.Time(next.UTC())}
+ }
+ }
+
err = api.Database.UpdateWorkspaceAutostart(ctx, database.UpdateWorkspaceAutostartParams{
ID: workspace.ID,
AutostartSchedule: dbSched,
+ NextStartAt: nextStartAt,
})
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
@@ -1895,6 +1914,11 @@ func convertWorkspace(
deletingAt = &workspace.DeletingAt.Time
}
+ var nextStartAt *time.Time
+ if workspace.NextStartAt.Valid {
+ nextStartAt = &workspace.NextStartAt.Time
+ }
+
failingAgents := []uuid.UUID{}
for _, resource := range workspaceBuild.Resources {
for _, agent := range resource.Agents {
@@ -1945,6 +1969,7 @@ func convertWorkspace(
AutomaticUpdates: codersdk.AutomaticUpdates(workspace.AutomaticUpdates),
AllowRenames: allowRenames,
Favorite: requesterFavorite,
+ NextStartAt: nextStartAt,
}, nil
}
diff --git a/codersdk/workspaces.go b/codersdk/workspaces.go
index bd94647382452..1e1e6c890e805 100644
--- a/codersdk/workspaces.go
+++ b/codersdk/workspaces.go
@@ -63,6 +63,7 @@ type Workspace struct {
AutomaticUpdates AutomaticUpdates `json:"automatic_updates" enums:"always,never"`
AllowRenames bool `json:"allow_renames"`
Favorite bool `json:"favorite"`
+ NextStartAt *time.Time `json:"next_start_at" format:"date-time"`
}
func (w Workspace) FullName() string {
diff --git a/docs/admin/security/audit-logs.md b/docs/admin/security/audit-logs.md
index db214b0e1443e..8f39e130a7dad 100644
--- a/docs/admin/security/audit-logs.md
+++ b/docs/admin/security/audit-logs.md
@@ -28,7 +28,7 @@ We track the following resources:
| User
create, write, delete |
Field | Tracked |
---|
avatar_url | false |
created_at | false |
deleted | true |
email | true |
github_com_user_id | false |
hashed_one_time_passcode | false |
hashed_password | true |
id | true |
last_seen_at | false |
login_type | true |
name | true |
one_time_passcode_expires_at | true |
quiet_hours_schedule | true |
rbac_roles | true |
status | true |
theme_preference | false |
updated_at | false |
username | true |
|
| WorkspaceBuild
start, stop | Field | Tracked |
---|
build_number | false |
created_at | false |
daily_cost | false |
deadline | false |
id | false |
initiator_by_avatar_url | false |
initiator_by_username | false |
initiator_id | false |
job_id | false |
max_deadline | false |
provisioner_state | false |
reason | false |
template_version_id | true |
transition | false |
updated_at | false |
workspace_id | false |
|
| WorkspaceProxy
| Field | Tracked |
---|
created_at | true |
deleted | false |
derp_enabled | true |
derp_only | true |
display_name | true |
icon | true |
id | true |
name | true |
region_id | true |
token_hashed_secret | true |
updated_at | false |
url | true |
version | true |
wildcard_hostname | true |
|
-| WorkspaceTable
| Field | Tracked |
---|
automatic_updates | true |
autostart_schedule | true |
created_at | false |
deleted | false |
deleting_at | true |
dormant_at | true |
favorite | true |
id | true |
last_used_at | false |
name | true |
organization_id | false |
owner_id | true |
template_id | true |
ttl | true |
updated_at | false |
|
+| WorkspaceTable
| Field | Tracked |
---|
automatic_updates | true |
autostart_schedule | true |
created_at | false |
deleted | false |
deleting_at | true |
dormant_at | true |
favorite | true |
id | true |
last_used_at | false |
name | true |
next_start_at | true |
organization_id | false |
owner_id | true |
template_id | true |
ttl | true |
updated_at | false |
|
diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md
index 35c677bccdda0..5256cb65aff76 100644
--- a/docs/reference/api/schemas.md
+++ b/docs/reference/api/schemas.md
@@ -6728,6 +6728,7 @@ If the schedule is empty, the user will be updated to use the default schedule.|
"workspace_owner_name": "string"
},
"name": "string",
+ "next_start_at": "2019-08-24T14:15:22Z",
"organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6",
"organization_name": "string",
"outdated": true,
@@ -6762,6 +6763,7 @@ If the schedule is empty, the user will be updated to use the default schedule.|
| `last_used_at` | string | false | | |
| `latest_build` | [codersdk.WorkspaceBuild](#codersdkworkspacebuild) | false | | |
| `name` | string | false | | |
+| `next_start_at` | string | false | | |
| `organization_id` | string | false | | |
| `organization_name` | string | false | | |
| `outdated` | boolean | false | | |
@@ -8048,6 +8050,7 @@ If the schedule is empty, the user will be updated to use the default schedule.|
"workspace_owner_name": "string"
},
"name": "string",
+ "next_start_at": "2019-08-24T14:15:22Z",
"organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6",
"organization_name": "string",
"outdated": true,
diff --git a/docs/reference/api/workspaces.md b/docs/reference/api/workspaces.md
index 183a59ddd13a3..531e5196233a2 100644
--- a/docs/reference/api/workspaces.md
+++ b/docs/reference/api/workspaces.md
@@ -217,6 +217,7 @@ of the template will be used.
"workspace_owner_name": "string"
},
"name": "string",
+ "next_start_at": "2019-08-24T14:15:22Z",
"organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6",
"organization_name": "string",
"outdated": true,
@@ -435,6 +436,7 @@ curl -X GET http://coder-server:8080/api/v2/users/{user}/workspace/{workspacenam
"workspace_owner_name": "string"
},
"name": "string",
+ "next_start_at": "2019-08-24T14:15:22Z",
"organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6",
"organization_name": "string",
"outdated": true,
@@ -677,6 +679,7 @@ of the template will be used.
"workspace_owner_name": "string"
},
"name": "string",
+ "next_start_at": "2019-08-24T14:15:22Z",
"organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6",
"organization_name": "string",
"outdated": true,
@@ -894,6 +897,7 @@ curl -X GET http://coder-server:8080/api/v2/workspaces \
"workspace_owner_name": "string"
},
"name": "string",
+ "next_start_at": "2019-08-24T14:15:22Z",
"organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6",
"organization_name": "string",
"outdated": true,
@@ -1113,6 +1117,7 @@ curl -X GET http://coder-server:8080/api/v2/workspaces/{workspace} \
"workspace_owner_name": "string"
},
"name": "string",
+ "next_start_at": "2019-08-24T14:15:22Z",
"organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6",
"organization_name": "string",
"outdated": true,
@@ -1447,6 +1452,7 @@ curl -X PUT http://coder-server:8080/api/v2/workspaces/{workspace}/dormant \
"workspace_owner_name": "string"
},
"name": "string",
+ "next_start_at": "2019-08-24T14:15:22Z",
"organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6",
"organization_name": "string",
"outdated": true,
diff --git a/enterprise/audit/table.go b/enterprise/audit/table.go
index 24f7dfa4b4fe0..4f27d8fe06b64 100644
--- a/enterprise/audit/table.go
+++ b/enterprise/audit/table.go
@@ -165,6 +165,7 @@ var auditableResourcesTypes = map[any]map[string]Action{
"deleting_at": ActionTrack,
"automatic_updates": ActionTrack,
"favorite": ActionTrack,
+ "next_start_at": ActionTrack,
},
&database.WorkspaceBuild{}: {
"id": ActionIgnore,
diff --git a/enterprise/coderd/coderd.go b/enterprise/coderd/coderd.go
index b6d60b5e4c20e..f8d6fc98edfe2 100644
--- a/enterprise/coderd/coderd.go
+++ b/enterprise/coderd/coderd.go
@@ -738,7 +738,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(api.AGPL.UserQuietHoursScheduleStore, api.NotificationsEnqueuer, api.Logger.Named("template.schedule-store"))
+ templateStore := schedule.NewEnterpriseTemplateScheduleStore(api.AGPL.UserQuietHoursScheduleStore, api.NotificationsEnqueuer, api.Logger.Named("template.schedule-store"), api.Clock)
templateStoreInterface := agplschedule.TemplateScheduleStore(templateStore)
api.AGPL.TemplateScheduleStore.Store(&templateStoreInterface)
diff --git a/enterprise/coderd/schedule/template.go b/enterprise/coderd/schedule/template.go
index a3f36e08dd218..82ec97b531a5a 100644
--- a/enterprise/coderd/schedule/template.go
+++ b/enterprise/coderd/schedule/template.go
@@ -21,6 +21,7 @@ import (
agpl "github.com/coder/coder/v2/coderd/schedule"
"github.com/coder/coder/v2/coderd/tracing"
"github.com/coder/coder/v2/codersdk"
+ "github.com/coder/quartz"
)
// EnterpriseTemplateScheduleStore provides an agpl.TemplateScheduleStore that
@@ -30,8 +31,8 @@ type EnterpriseTemplateScheduleStore struct {
// update.
UserQuietHoursScheduleStore *atomic.Pointer[agpl.UserQuietHoursScheduleStore]
- // Custom time.Now() function to use in tests. Defaults to dbtime.Now().
- TimeNowFn func() time.Time
+ // Clock for testing
+ Clock quartz.Clock
enqueuer notifications.Enqueuer
logger slog.Logger
@@ -39,19 +40,21 @@ type EnterpriseTemplateScheduleStore struct {
var _ agpl.TemplateScheduleStore = &EnterpriseTemplateScheduleStore{}
-func NewEnterpriseTemplateScheduleStore(userQuietHoursStore *atomic.Pointer[agpl.UserQuietHoursScheduleStore], enqueuer notifications.Enqueuer, logger slog.Logger) *EnterpriseTemplateScheduleStore {
+func NewEnterpriseTemplateScheduleStore(userQuietHoursStore *atomic.Pointer[agpl.UserQuietHoursScheduleStore], enqueuer notifications.Enqueuer, logger slog.Logger, clock quartz.Clock) *EnterpriseTemplateScheduleStore {
+ if clock == nil {
+ clock = quartz.NewReal()
+ }
+
return &EnterpriseTemplateScheduleStore{
UserQuietHoursScheduleStore: userQuietHoursStore,
+ Clock: clock,
enqueuer: enqueuer,
logger: logger,
}
}
func (s *EnterpriseTemplateScheduleStore) now() time.Time {
- if s.TimeNowFn != nil {
- return s.TimeNowFn()
- }
- return dbtime.Now()
+ return dbtime.Time(s.Clock.Now())
}
// Get implements agpl.TemplateScheduleStore.
@@ -164,7 +167,7 @@ func (s *EnterpriseTemplateScheduleStore) Set(ctx context.Context, db database.S
var dormantAt time.Time
if opts.UpdateWorkspaceDormantAt {
- dormantAt = dbtime.Now()
+ dormantAt = s.now()
}
// If we updated the time_til_dormant_autodelete we need to update all the workspaces deleting_at
@@ -205,8 +208,45 @@ func (s *EnterpriseTemplateScheduleStore) Set(ctx context.Context, db database.S
return database.Template{}, err
}
+ if opts.AutostartRequirement.DaysOfWeek != tpl.AutostartAllowedDays() {
+ templateSchedule, err := s.Get(ctx, db, tpl.ID)
+ if err != nil {
+ return database.Template{}, xerrors.Errorf("get template schedule: %w", err)
+ }
+
+ //nolint:gocritic // We need to be able to read information about all workspaces.
+ workspaces, err := db.GetWorkspacesByTemplateID(dbauthz.AsSystemRestricted(ctx), tpl.ID)
+ if err != nil {
+ return database.Template{}, xerrors.Errorf("get workspaces by template id: %w", err)
+ }
+
+ workspaceIDs := []uuid.UUID{}
+ nextStartAts := []time.Time{}
+
+ for _, workspace := range workspaces {
+ nextStartAt := time.Time{}
+ if workspace.AutostartSchedule.Valid {
+ next, err := agpl.NextAllowedAutostart(s.now(), workspace.AutostartSchedule.String, templateSchedule)
+ if err == nil {
+ nextStartAt = dbtime.Time(next.UTC())
+ }
+ }
+
+ workspaceIDs = append(workspaceIDs, workspace.ID)
+ nextStartAts = append(nextStartAts, nextStartAt)
+ }
+
+ //nolint:gocritic // We need to be able to update information about all workspaces.
+ if err := db.BatchUpdateWorkspaceNextStartAt(dbauthz.AsSystemRestricted(ctx), database.BatchUpdateWorkspaceNextStartAtParams{
+ IDs: workspaceIDs,
+ NextStartAts: nextStartAts,
+ }); err != nil {
+ return database.Template{}, xerrors.Errorf("update workspace next start at: %w", err)
+ }
+ }
+
for _, ws := range markedForDeletion {
- dormantTime := dbtime.Now().Add(opts.TimeTilDormantAutoDelete)
+ dormantTime := s.now().Add(opts.TimeTilDormantAutoDelete)
_, err = s.enqueuer.Enqueue(
// nolint:gocritic // Need actor to enqueue notification
dbauthz.AsNotifier(ctx),
@@ -304,6 +344,23 @@ func (s *EnterpriseTemplateScheduleStore) updateWorkspaceBuild(ctx context.Conte
return xerrors.Errorf("calculate new autostop for workspace %q: %w", workspace.ID, err)
}
+ if workspace.AutostartSchedule.Valid {
+ templateScheduleOptions, err := s.Get(ctx, db, workspace.TemplateID)
+ if err != nil {
+ return xerrors.Errorf("get template schedule options: %w", err)
+ }
+
+ nextStartAt, _ := agpl.NextAutostart(s.now(), workspace.AutostartSchedule.String, templateScheduleOptions)
+
+ err = db.UpdateWorkspaceNextStartAt(ctx, database.UpdateWorkspaceNextStartAtParams{
+ ID: workspace.ID,
+ NextStartAt: sql.NullTime{Valid: true, Time: nextStartAt},
+ })
+ if err != nil {
+ return xerrors.Errorf("update workspace next start at: %w", err)
+ }
+ }
+
// If max deadline is before now()+2h, then set it to that.
// This is intended to give ample warning to this workspace about an upcoming auto-stop.
// If we were to omit this "grace" period, then this workspace could be set to be stopped "now".
diff --git a/enterprise/coderd/schedule/template_test.go b/enterprise/coderd/schedule/template_test.go
index ee84dbe90ff78..5e3c9fd658cf3 100644
--- a/enterprise/coderd/schedule/template_test.go
+++ b/enterprise/coderd/schedule/template_test.go
@@ -26,6 +26,7 @@ import (
"github.com/coder/coder/v2/cryptorand"
"github.com/coder/coder/v2/enterprise/coderd/schedule"
"github.com/coder/coder/v2/testutil"
+ "github.com/coder/quartz"
)
func TestTemplateUpdateBuildDeadlines(t *testing.T) {
@@ -283,11 +284,11 @@ func TestTemplateUpdateBuildDeadlines(t *testing.T) {
userQuietHoursStorePtr := &atomic.Pointer[agplschedule.UserQuietHoursScheduleStore]{}
userQuietHoursStorePtr.Store(&userQuietHoursStore)
+ clock := quartz.NewMock(t)
+ clock.Set(c.now)
+
// Set the template policy.
- templateScheduleStore := schedule.NewEnterpriseTemplateScheduleStore(userQuietHoursStorePtr, notifications.NewNoopEnqueuer(), logger)
- templateScheduleStore.TimeNowFn = func() time.Time {
- return c.now
- }
+ templateScheduleStore := schedule.NewEnterpriseTemplateScheduleStore(userQuietHoursStorePtr, notifications.NewNoopEnqueuer(), logger, clock)
autostopReq := agplschedule.TemplateAutostopRequirement{
// Every day
@@ -570,11 +571,11 @@ func TestTemplateUpdateBuildDeadlinesSkip(t *testing.T) {
userQuietHoursStorePtr := &atomic.Pointer[agplschedule.UserQuietHoursScheduleStore]{}
userQuietHoursStorePtr.Store(&userQuietHoursStore)
+ clock := quartz.NewMock(t)
+ clock.Set(now)
+
// Set the template policy.
- templateScheduleStore := schedule.NewEnterpriseTemplateScheduleStore(userQuietHoursStorePtr, notifications.NewNoopEnqueuer(), logger)
- templateScheduleStore.TimeNowFn = func() time.Time {
- return now
- }
+ templateScheduleStore := schedule.NewEnterpriseTemplateScheduleStore(userQuietHoursStorePtr, notifications.NewNoopEnqueuer(), logger, clock)
_, err = templateScheduleStore.Set(ctx, db, template, agplschedule.TemplateScheduleOptions{
UserAutostartEnabled: false,
UserAutostopEnabled: false,
@@ -682,8 +683,7 @@ func TestNotifications(t *testing.T) {
require.NoError(t, err)
userQuietHoursStorePtr := &atomic.Pointer[agplschedule.UserQuietHoursScheduleStore]{}
userQuietHoursStorePtr.Store(&userQuietHoursStore)
- templateScheduleStore := schedule.NewEnterpriseTemplateScheduleStore(userQuietHoursStorePtr, ¬ifyEnq, logger)
- templateScheduleStore.TimeNowFn = time.Now
+ templateScheduleStore := schedule.NewEnterpriseTemplateScheduleStore(userQuietHoursStorePtr, ¬ifyEnq, logger, nil)
// Lower the dormancy TTL to ensure the schedule recalculates deadlines and
// triggers notifications.
diff --git a/enterprise/coderd/templates_test.go b/enterprise/coderd/templates_test.go
index 2fc6bf9fda087..22314f45bb3c7 100644
--- a/enterprise/coderd/templates_test.go
+++ b/enterprise/coderd/templates_test.go
@@ -689,7 +689,7 @@ func TestTemplates(t *testing.T) {
client, user := coderdenttest.New(t, &coderdenttest.Options{
Options: &coderdtest.Options{
IncludeProvisionerDaemon: true,
- TemplateScheduleStore: schedule.NewEnterpriseTemplateScheduleStore(agplUserQuietHoursScheduleStore(), notifications.NewNoopEnqueuer(), logger),
+ TemplateScheduleStore: schedule.NewEnterpriseTemplateScheduleStore(agplUserQuietHoursScheduleStore(), notifications.NewNoopEnqueuer(), logger, nil),
},
LicenseOptions: &coderdenttest.LicenseOptions{
Features: license.Features{
@@ -739,7 +739,7 @@ func TestTemplates(t *testing.T) {
owner, first := coderdenttest.New(t, &coderdenttest.Options{
Options: &coderdtest.Options{
IncludeProvisionerDaemon: true,
- TemplateScheduleStore: schedule.NewEnterpriseTemplateScheduleStore(agplUserQuietHoursScheduleStore(), notifications.NewNoopEnqueuer(), logger),
+ TemplateScheduleStore: schedule.NewEnterpriseTemplateScheduleStore(agplUserQuietHoursScheduleStore(), notifications.NewNoopEnqueuer(), logger, nil),
},
LicenseOptions: &coderdenttest.LicenseOptions{
Features: license.Features{
diff --git a/enterprise/coderd/workspaces_test.go b/enterprise/coderd/workspaces_test.go
index 239c7ae377102..fcaeb0f62038a 100644
--- a/enterprise/coderd/workspaces_test.go
+++ b/enterprise/coderd/workspaces_test.go
@@ -2,11 +2,13 @@ package coderd_test
import (
"context"
+ "database/sql"
"net/http"
"sync/atomic"
"testing"
"time"
+ "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"cdr.dev/slog"
@@ -17,8 +19,10 @@ import (
"github.com/coder/coder/v2/coderd/autobuild"
"github.com/coder/coder/v2/coderd/coderdtest"
"github.com/coder/coder/v2/coderd/database"
+ "github.com/coder/coder/v2/coderd/database/dbauthz"
"github.com/coder/coder/v2/coderd/database/dbfake"
"github.com/coder/coder/v2/coderd/database/dbtestutil"
+ "github.com/coder/coder/v2/coderd/database/dbtime"
"github.com/coder/coder/v2/coderd/notifications"
"github.com/coder/coder/v2/coderd/rbac"
agplschedule "github.com/coder/coder/v2/coderd/schedule"
@@ -32,6 +36,7 @@ import (
"github.com/coder/coder/v2/enterprise/coderd/schedule"
"github.com/coder/coder/v2/provisioner/echo"
"github.com/coder/coder/v2/testutil"
+ "github.com/coder/quartz"
)
// agplUserQuietHoursScheduleStore is passed to
@@ -295,7 +300,7 @@ func TestWorkspaceAutobuild(t *testing.T) {
AutobuildTicker: ticker,
IncludeProvisionerDaemon: true,
AutobuildStats: statCh,
- TemplateScheduleStore: schedule.NewEnterpriseTemplateScheduleStore(agplUserQuietHoursScheduleStore(), notifications.NewNoopEnqueuer(), logger),
+ TemplateScheduleStore: schedule.NewEnterpriseTemplateScheduleStore(agplUserQuietHoursScheduleStore(), notifications.NewNoopEnqueuer(), logger, nil),
},
LicenseOptions: &coderdenttest.LicenseOptions{
Features: license.Features{codersdk.FeatureAdvancedTemplateScheduling: 1},
@@ -342,7 +347,7 @@ func TestWorkspaceAutobuild(t *testing.T) {
AutobuildTicker: ticker,
IncludeProvisionerDaemon: true,
AutobuildStats: statCh,
- TemplateScheduleStore: schedule.NewEnterpriseTemplateScheduleStore(agplUserQuietHoursScheduleStore(), notifications.NewNoopEnqueuer(), logger),
+ TemplateScheduleStore: schedule.NewEnterpriseTemplateScheduleStore(agplUserQuietHoursScheduleStore(), notifications.NewNoopEnqueuer(), logger, nil),
},
LicenseOptions: &coderdenttest.LicenseOptions{
Features: license.Features{codersdk.FeatureAdvancedTemplateScheduling: 1},
@@ -388,7 +393,7 @@ func TestWorkspaceAutobuild(t *testing.T) {
AutobuildTicker: ticker,
IncludeProvisionerDaemon: true,
AutobuildStats: statCh,
- TemplateScheduleStore: schedule.NewEnterpriseTemplateScheduleStore(agplUserQuietHoursScheduleStore(), notifications.NewNoopEnqueuer(), logger),
+ TemplateScheduleStore: schedule.NewEnterpriseTemplateScheduleStore(agplUserQuietHoursScheduleStore(), notifications.NewNoopEnqueuer(), logger, nil),
},
LicenseOptions: &coderdenttest.LicenseOptions{
Features: license.Features{codersdk.FeatureAdvancedTemplateScheduling: 1},
@@ -432,7 +437,7 @@ func TestWorkspaceAutobuild(t *testing.T) {
Options: &coderdtest.Options{
AutobuildTicker: ticker,
AutobuildStats: statCh,
- TemplateScheduleStore: schedule.NewEnterpriseTemplateScheduleStore(agplUserQuietHoursScheduleStore(), notifications.NewNoopEnqueuer(), logger),
+ TemplateScheduleStore: schedule.NewEnterpriseTemplateScheduleStore(agplUserQuietHoursScheduleStore(), notifications.NewNoopEnqueuer(), logger, nil),
Auditor: auditRecorder,
},
LicenseOptions: &coderdenttest.LicenseOptions{
@@ -527,7 +532,7 @@ func TestWorkspaceAutobuild(t *testing.T) {
Options: &coderdtest.Options{
AutobuildTicker: ticker,
AutobuildStats: statCh,
- TemplateScheduleStore: schedule.NewEnterpriseTemplateScheduleStore(agplUserQuietHoursScheduleStore(), notifications.NewNoopEnqueuer(), logger),
+ TemplateScheduleStore: schedule.NewEnterpriseTemplateScheduleStore(agplUserQuietHoursScheduleStore(), notifications.NewNoopEnqueuer(), logger, nil),
Database: db,
Pubsub: pubsub,
Auditor: auditor,
@@ -585,7 +590,7 @@ func TestWorkspaceAutobuild(t *testing.T) {
AutobuildTicker: ticker,
IncludeProvisionerDaemon: true,
AutobuildStats: statCh,
- TemplateScheduleStore: schedule.NewEnterpriseTemplateScheduleStore(agplUserQuietHoursScheduleStore(), notifications.NewNoopEnqueuer(), logger),
+ TemplateScheduleStore: schedule.NewEnterpriseTemplateScheduleStore(agplUserQuietHoursScheduleStore(), notifications.NewNoopEnqueuer(), logger, nil),
},
LicenseOptions: &coderdenttest.LicenseOptions{
Features: license.Features{codersdk.FeatureAdvancedTemplateScheduling: 1},
@@ -628,7 +633,7 @@ func TestWorkspaceAutobuild(t *testing.T) {
AutobuildTicker: ticker,
IncludeProvisionerDaemon: true,
AutobuildStats: statCh,
- TemplateScheduleStore: schedule.NewEnterpriseTemplateScheduleStore(agplUserQuietHoursScheduleStore(), notifications.NewNoopEnqueuer(), logger),
+ TemplateScheduleStore: schedule.NewEnterpriseTemplateScheduleStore(agplUserQuietHoursScheduleStore(), notifications.NewNoopEnqueuer(), logger, nil),
},
LicenseOptions: &coderdenttest.LicenseOptions{
Features: license.Features{codersdk.FeatureAdvancedTemplateScheduling: 1},
@@ -671,7 +676,7 @@ func TestWorkspaceAutobuild(t *testing.T) {
AutobuildTicker: ticker,
IncludeProvisionerDaemon: true,
AutobuildStats: statCh,
- TemplateScheduleStore: schedule.NewEnterpriseTemplateScheduleStore(agplUserQuietHoursScheduleStore(), notifications.NewNoopEnqueuer(), logger),
+ TemplateScheduleStore: schedule.NewEnterpriseTemplateScheduleStore(agplUserQuietHoursScheduleStore(), notifications.NewNoopEnqueuer(), logger, nil),
},
LicenseOptions: &coderdenttest.LicenseOptions{
Features: license.Features{codersdk.FeatureAdvancedTemplateScheduling: 1},
@@ -725,7 +730,7 @@ func TestWorkspaceAutobuild(t *testing.T) {
AutobuildTicker: ticker,
IncludeProvisionerDaemon: true,
AutobuildStats: statCh,
- TemplateScheduleStore: schedule.NewEnterpriseTemplateScheduleStore(agplUserQuietHoursScheduleStore(), notifications.NewNoopEnqueuer(), logger),
+ TemplateScheduleStore: schedule.NewEnterpriseTemplateScheduleStore(agplUserQuietHoursScheduleStore(), notifications.NewNoopEnqueuer(), logger, nil),
},
LicenseOptions: &coderdenttest.LicenseOptions{
Features: license.Features{codersdk.FeatureAdvancedTemplateScheduling: 1},
@@ -797,7 +802,7 @@ func TestWorkspaceAutobuild(t *testing.T) {
AutobuildTicker: ticker,
IncludeProvisionerDaemon: true,
AutobuildStats: statCh,
- TemplateScheduleStore: schedule.NewEnterpriseTemplateScheduleStore(agplUserQuietHoursScheduleStore(), notifications.NewNoopEnqueuer(), logger),
+ TemplateScheduleStore: schedule.NewEnterpriseTemplateScheduleStore(agplUserQuietHoursScheduleStore(), notifications.NewNoopEnqueuer(), logger, nil),
},
LicenseOptions: &coderdenttest.LicenseOptions{
Features: license.Features{codersdk.FeatureAdvancedTemplateScheduling: 1},
@@ -861,7 +866,7 @@ func TestWorkspaceAutobuild(t *testing.T) {
AutobuildTicker: tickCh,
IncludeProvisionerDaemon: true,
AutobuildStats: statsCh,
- TemplateScheduleStore: schedule.NewEnterpriseTemplateScheduleStore(agplUserQuietHoursScheduleStore(), notifications.NewNoopEnqueuer(), logger),
+ TemplateScheduleStore: schedule.NewEnterpriseTemplateScheduleStore(agplUserQuietHoursScheduleStore(), notifications.NewNoopEnqueuer(), logger, nil),
},
LicenseOptions: &coderdenttest.LicenseOptions{
Features: license.Features{codersdk.FeatureAdvancedTemplateScheduling: 1},
@@ -941,7 +946,7 @@ func TestWorkspaceAutobuild(t *testing.T) {
AutobuildTicker: ticker,
IncludeProvisionerDaemon: true,
AutobuildStats: statCh,
- TemplateScheduleStore: schedule.NewEnterpriseTemplateScheduleStore(agplUserQuietHoursScheduleStore(), notifications.NewNoopEnqueuer(), logger),
+ TemplateScheduleStore: schedule.NewEnterpriseTemplateScheduleStore(agplUserQuietHoursScheduleStore(), notifications.NewNoopEnqueuer(), logger, nil),
},
LicenseOptions: &coderdenttest.LicenseOptions{
Features: license.Features{codersdk.FeatureAdvancedTemplateScheduling: 1},
@@ -1027,7 +1032,7 @@ func TestWorkspaceAutobuild(t *testing.T) {
AutobuildTicker: tickCh,
IncludeProvisionerDaemon: true,
AutobuildStats: statsCh,
- TemplateScheduleStore: schedule.NewEnterpriseTemplateScheduleStore(agplUserQuietHoursScheduleStore(), notifications.NewNoopEnqueuer(), logger),
+ TemplateScheduleStore: schedule.NewEnterpriseTemplateScheduleStore(agplUserQuietHoursScheduleStore(), notifications.NewNoopEnqueuer(), logger, nil),
},
LicenseOptions: &coderdenttest.LicenseOptions{
Features: license.Features{codersdk.FeatureAccessControl: 1},
@@ -1102,6 +1107,245 @@ func TestWorkspaceAutobuild(t *testing.T) {
ws = coderdtest.MustWorkspace(t, client, ws.ID)
require.Equal(t, version2.ID, ws.LatestBuild.TemplateVersionID)
})
+
+ t.Run("NextStartAtIsValid", func(t *testing.T) {
+ t.Parallel()
+
+ var (
+ tickCh = make(chan time.Time)
+ statsCh = make(chan autobuild.Stats)
+ clock = quartz.NewMock(t)
+ )
+
+ // Set the clock to 8AM Monday, 1st January, 2024 to keep
+ // this test deterministic.
+ clock.Set(time.Date(2024, 1, 1, 8, 0, 0, 0, time.UTC))
+
+ logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug)
+ client, user := coderdenttest.New(t, &coderdenttest.Options{
+ Options: &coderdtest.Options{
+ AutobuildTicker: tickCh,
+ IncludeProvisionerDaemon: true,
+ AutobuildStats: statsCh,
+ Logger: &logger,
+ Clock: clock,
+ TemplateScheduleStore: schedule.NewEnterpriseTemplateScheduleStore(agplUserQuietHoursScheduleStore(), notifications.NewNoopEnqueuer(), logger, nil),
+ },
+ LicenseOptions: &coderdenttest.LicenseOptions{
+ Features: license.Features{codersdk.FeatureAdvancedTemplateScheduling: 1},
+ },
+ })
+
+ version1 := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
+ coderdtest.AwaitTemplateVersionJobCompleted(t, client, version1.ID)
+
+ // First create a template that only supports Monday-Friday
+ template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version1.ID, func(ctr *codersdk.CreateTemplateRequest) {
+ ctr.AutostartRequirement = &codersdk.TemplateAutostartRequirement{DaysOfWeek: codersdk.BitmapToWeekdays(0b00011111)}
+ })
+ require.Equal(t, version1.ID, template.ActiveVersionID)
+
+ // Then create a workspace with a schedule Sunday-Saturday
+ sched, err := cron.Weekly("CRON_TZ=UTC 0 9 * * 0-6")
+ require.NoError(t, err)
+ ws := coderdtest.CreateWorkspace(t, client, template.ID, func(cwr *codersdk.CreateWorkspaceRequest) {
+ cwr.AutostartSchedule = ptr.Ref(sched.String())
+ })
+
+ coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, ws.LatestBuild.ID)
+ ws = coderdtest.MustTransitionWorkspace(t, client, ws.ID, database.WorkspaceTransitionStart, database.WorkspaceTransitionStop)
+ next := ws.LatestBuild.CreatedAt
+
+ // For each day of the week (Monday-Sunday)
+ // We iterate through each day of the week to ensure the behavior of each
+ // day of the week is as expected.
+ for range 7 {
+ next = sched.Next(next)
+
+ clock.Set(next)
+ tickCh <- next
+ stats := <-statsCh
+ ws = coderdtest.MustWorkspace(t, client, ws.ID)
+
+ // Our cron schedule specifies Sunday-Saturday but the template only allows
+ // Monday-Friday so we expect there to be no transitions on the weekend.
+ if next.Weekday() == time.Saturday || next.Weekday() == time.Sunday {
+ assert.Len(t, stats.Errors, 0)
+ assert.Len(t, stats.Transitions, 0)
+
+ ws = coderdtest.MustWorkspace(t, client, ws.ID)
+ } else {
+ assert.Len(t, stats.Errors, 0)
+ assert.Len(t, stats.Transitions, 1)
+ assert.Contains(t, stats.Transitions, ws.ID)
+ assert.Equal(t, database.WorkspaceTransitionStart, stats.Transitions[ws.ID])
+
+ coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, ws.LatestBuild.ID)
+ ws = coderdtest.MustTransitionWorkspace(t, client, ws.ID, database.WorkspaceTransitionStart, database.WorkspaceTransitionStop)
+ }
+
+ // Ensure that there is a valid next start at and that is is after
+ // the preivous start.
+ require.NotNil(t, ws.NextStartAt)
+ require.Greater(t, *ws.NextStartAt, next)
+
+ // Our autostart requirement disallows sundays and saturdays so
+ // the next start at should never land on these days.
+ require.NotEqual(t, time.Saturday, ws.NextStartAt.Weekday())
+ require.NotEqual(t, time.Sunday, ws.NextStartAt.Weekday())
+ }
+ })
+
+ t.Run("NextStartAtIsUpdatedWhenTemplateAutostartRequirementsChange", func(t *testing.T) {
+ t.Parallel()
+
+ var (
+ tickCh = make(chan time.Time)
+ statsCh = make(chan autobuild.Stats)
+ clock = quartz.NewMock(t)
+ )
+
+ // Set the clock to 8AM Monday, 1st January, 2024 to keep
+ // this test deterministic.
+ clock.Set(time.Date(2024, 1, 1, 8, 0, 0, 0, time.UTC))
+
+ logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug)
+ templateScheduleStore := schedule.NewEnterpriseTemplateScheduleStore(agplUserQuietHoursScheduleStore(), notifications.NewNoopEnqueuer(), logger, nil)
+ templateScheduleStore.Clock = clock
+ client, user := coderdenttest.New(t, &coderdenttest.Options{
+ Options: &coderdtest.Options{
+ AutobuildTicker: tickCh,
+ IncludeProvisionerDaemon: true,
+ AutobuildStats: statsCh,
+ Logger: &logger,
+ Clock: clock,
+ TemplateScheduleStore: templateScheduleStore,
+ },
+ LicenseOptions: &coderdenttest.LicenseOptions{
+ Features: license.Features{codersdk.FeatureAdvancedTemplateScheduling: 1},
+ },
+ })
+
+ version1 := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
+ coderdtest.AwaitTemplateVersionJobCompleted(t, client, version1.ID)
+
+ // First create a template that only supports Monday-Friday
+ template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version1.ID, func(ctr *codersdk.CreateTemplateRequest) {
+ ctr.AllowUserAutostart = ptr.Ref(true)
+ ctr.AutostartRequirement = &codersdk.TemplateAutostartRequirement{DaysOfWeek: codersdk.BitmapToWeekdays(0b00011111)}
+ })
+ require.Equal(t, version1.ID, template.ActiveVersionID)
+
+ // Then create a workspace with a schedule Monday-Friday
+ sched, err := cron.Weekly("CRON_TZ=UTC 0 9 * * 1-5")
+ require.NoError(t, err)
+ ws := coderdtest.CreateWorkspace(t, client, template.ID, func(cwr *codersdk.CreateWorkspaceRequest) {
+ cwr.AutostartSchedule = ptr.Ref(sched.String())
+ })
+
+ coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, ws.LatestBuild.ID)
+ ws = coderdtest.MustTransitionWorkspace(t, client, ws.ID, database.WorkspaceTransitionStart, database.WorkspaceTransitionStop)
+
+ // Our next start at should be Monday
+ require.NotNil(t, ws.NextStartAt)
+ require.Equal(t, time.Monday, ws.NextStartAt.Weekday())
+
+ // Now update the template to only allow Tuesday-Friday
+ coderdtest.UpdateTemplateMeta(t, client, template.ID, codersdk.UpdateTemplateMeta{
+ AutostartRequirement: &codersdk.TemplateAutostartRequirement{
+ DaysOfWeek: codersdk.BitmapToWeekdays(0b00011110),
+ },
+ })
+
+ // Verify that our next start at has been updated to Tuesday
+ ws = coderdtest.MustWorkspace(t, client, ws.ID)
+ require.NotNil(t, ws.NextStartAt)
+ require.Equal(t, time.Tuesday, ws.NextStartAt.Weekday())
+ })
+
+ t.Run("NextStartAtIsNullifiedOnScheduleChange", func(t *testing.T) {
+ t.Parallel()
+
+ if !dbtestutil.WillUsePostgres() {
+ t.Skip("this test uses triggers so does not work with dbmem.go")
+ }
+
+ var (
+ tickCh = make(chan time.Time)
+ statsCh = make(chan autobuild.Stats)
+ )
+
+ logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug)
+ client, db, user := coderdenttest.NewWithDatabase(t, &coderdenttest.Options{
+ Options: &coderdtest.Options{
+ AutobuildTicker: tickCh,
+ IncludeProvisionerDaemon: true,
+ AutobuildStats: statsCh,
+ Logger: &logger,
+ TemplateScheduleStore: schedule.NewEnterpriseTemplateScheduleStore(agplUserQuietHoursScheduleStore(), notifications.NewNoopEnqueuer(), logger, nil),
+ },
+ LicenseOptions: &coderdenttest.LicenseOptions{
+ Features: license.Features{codersdk.FeatureAdvancedTemplateScheduling: 1},
+ },
+ })
+
+ version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
+ coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
+
+ // Create a template that allows autostart Monday-Sunday
+ template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID, func(ctr *codersdk.CreateTemplateRequest) {
+ ctr.AutostartRequirement = &codersdk.TemplateAutostartRequirement{DaysOfWeek: codersdk.AllDaysOfWeek}
+ })
+ require.Equal(t, version.ID, template.ActiveVersionID)
+
+ // Create a workspace with a schedule Sunday-Saturday
+ sched, err := cron.Weekly("CRON_TZ=UTC 0 9 * * 0-6")
+ require.NoError(t, err)
+ ws := coderdtest.CreateWorkspace(t, client, template.ID, func(cwr *codersdk.CreateWorkspaceRequest) {
+ cwr.AutostartSchedule = ptr.Ref(sched.String())
+ })
+
+ coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, ws.LatestBuild.ID)
+ ws = coderdtest.MustTransitionWorkspace(t, client, ws.ID, database.WorkspaceTransitionStart, database.WorkspaceTransitionStop)
+
+ // Check we have a 'NextStartAt'
+ require.NotNil(t, ws.NextStartAt)
+
+ // Create a new slightly different cron schedule that could
+ // potentially make NextStartAt invalid.
+ sched, err = cron.Weekly("CRON_TZ=UTC 0 9 * * 1-6")
+ require.NoError(t, err)
+ ctx := testutil.Context(t, testutil.WaitShort)
+
+ // We want to test the database nullifies the NextStartAt so we
+ // make a raw DB call here. We pass in NextStartAt here so we
+ // can test the database will nullify it and not us.
+ //nolint: gocritic // We need system context to modify this.
+ err = db.UpdateWorkspaceAutostart(dbauthz.AsSystemRestricted(ctx), database.UpdateWorkspaceAutostartParams{
+ ID: ws.ID,
+ AutostartSchedule: sql.NullString{Valid: true, String: sched.String()},
+ NextStartAt: sql.NullTime{Valid: true, Time: *ws.NextStartAt},
+ })
+ require.NoError(t, err)
+
+ ws = coderdtest.MustWorkspace(t, client, ws.ID)
+
+ // Check 'NextStartAt' has been nullified
+ require.Nil(t, ws.NextStartAt)
+
+ // Now we let the lifecycle executor run. This should spot that the
+ // NextStartAt is null and update it for us.
+ next := dbtime.Now()
+ tickCh <- next
+ stats := <-statsCh
+ assert.Len(t, stats.Errors, 0)
+ assert.Len(t, stats.Transitions, 0)
+
+ // Ensure NextStartAt has been set, and is the expected value
+ ws = coderdtest.MustWorkspace(t, client, ws.ID)
+ require.NotNil(t, ws.NextStartAt)
+ require.Equal(t, sched.Next(next), ws.NextStartAt.UTC())
+ })
}
func TestTemplateDoesNotAllowUserAutostop(t *testing.T) {
@@ -1112,7 +1356,7 @@ func TestTemplateDoesNotAllowUserAutostop(t *testing.T) {
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug)
client := coderdtest.New(t, &coderdtest.Options{
IncludeProvisionerDaemon: true,
- TemplateScheduleStore: schedule.NewEnterpriseTemplateScheduleStore(agplUserQuietHoursScheduleStore(), notifications.NewNoopEnqueuer(), logger),
+ TemplateScheduleStore: schedule.NewEnterpriseTemplateScheduleStore(agplUserQuietHoursScheduleStore(), notifications.NewNoopEnqueuer(), logger, nil),
})
user := coderdtest.CreateFirstUser(t, client)
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
@@ -1151,7 +1395,7 @@ func TestTemplateDoesNotAllowUserAutostop(t *testing.T) {
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug)
client := coderdtest.New(t, &coderdtest.Options{
IncludeProvisionerDaemon: true,
- TemplateScheduleStore: schedule.NewEnterpriseTemplateScheduleStore(agplUserQuietHoursScheduleStore(), notifications.NewNoopEnqueuer(), logger),
+ TemplateScheduleStore: schedule.NewEnterpriseTemplateScheduleStore(agplUserQuietHoursScheduleStore(), notifications.NewNoopEnqueuer(), logger, nil),
})
user := coderdtest.CreateFirstUser(t, client)
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
@@ -1203,7 +1447,7 @@ func TestExecutorAutostartBlocked(t *testing.T) {
AutobuildTicker: tickCh,
IncludeProvisionerDaemon: true,
AutobuildStats: statsCh,
- TemplateScheduleStore: schedule.NewEnterpriseTemplateScheduleStore(agplUserQuietHoursScheduleStore(), notifications.NewNoopEnqueuer(), logger),
+ TemplateScheduleStore: schedule.NewEnterpriseTemplateScheduleStore(agplUserQuietHoursScheduleStore(), notifications.NewNoopEnqueuer(), logger, nil),
},
LicenseOptions: &coderdenttest.LicenseOptions{
Features: license.Features{codersdk.FeatureAdvancedTemplateScheduling: 1},
@@ -1225,9 +1469,9 @@ func TestExecutorAutostartBlocked(t *testing.T) {
// Given: workspace is stopped
workspace = coderdtest.MustTransitionWorkspace(t, client, workspace.ID, database.WorkspaceTransitionStart, database.WorkspaceTransitionStop)
- // When: the autobuild executor ticks way into the future
+ // When: the autobuild executor ticks into the future
go func() {
- tickCh <- workspace.LatestBuild.CreatedAt.Add(24 * time.Hour)
+ tickCh <- workspace.LatestBuild.CreatedAt.Add(2 * time.Hour)
close(tickCh)
}()
@@ -1247,7 +1491,7 @@ func TestWorkspacesFiltering(t *testing.T) {
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug)
client, db, owner := coderdenttest.NewWithDatabase(t, &coderdenttest.Options{
Options: &coderdtest.Options{
- TemplateScheduleStore: schedule.NewEnterpriseTemplateScheduleStore(agplUserQuietHoursScheduleStore(), notifications.NewNoopEnqueuer(), logger),
+ TemplateScheduleStore: schedule.NewEnterpriseTemplateScheduleStore(agplUserQuietHoursScheduleStore(), notifications.NewNoopEnqueuer(), logger, nil),
},
LicenseOptions: &coderdenttest.LicenseOptions{
Features: license.Features{codersdk.FeatureAdvancedTemplateScheduling: 1},
@@ -1362,7 +1606,7 @@ func TestWorkspaceLock(t *testing.T) {
client, user = coderdenttest.New(t, &coderdenttest.Options{
Options: &coderdtest.Options{
IncludeProvisionerDaemon: true,
- TemplateScheduleStore: &schedule.EnterpriseTemplateScheduleStore{},
+ TemplateScheduleStore: &schedule.EnterpriseTemplateScheduleStore{Clock: quartz.NewReal()},
},
LicenseOptions: &coderdenttest.LicenseOptions{
Features: license.Features{
@@ -1423,7 +1667,7 @@ func TestResolveAutostart(t *testing.T) {
ownerClient, db, owner := coderdenttest.NewWithDatabase(t, &coderdenttest.Options{
Options: &coderdtest.Options{
- TemplateScheduleStore: &schedule.EnterpriseTemplateScheduleStore{},
+ TemplateScheduleStore: &schedule.EnterpriseTemplateScheduleStore{Clock: quartz.NewReal()},
},
LicenseOptions: &coderdenttest.LicenseOptions{
Features: license.Features{
diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts
index c1b409013b6d7..24f8919dae676 100644
--- a/site/src/api/typesGenerated.ts
+++ b/site/src/api/typesGenerated.ts
@@ -1847,6 +1847,7 @@ export interface Workspace {
readonly automatic_updates: AutomaticUpdates;
readonly allow_renames: boolean;
readonly favorite: boolean;
+ readonly next_start_at?: string;
}
// From codersdk/workspaceagents.go