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
pFad - Phonifier reborn
Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.
Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.
Alternative Proxies:
Alternative Proxy
pFad Proxy
pFad v3 Proxy
pFad v4 Proxy