fix: make GetWorkspacesEligibleForTransition return even less false positives (#15594)

Relates to https://github.com/coder/coder/issues/15082

Further to https://github.com/coder/coder/pull/15429, this reduces the
amount of false-positives returned by the 'is eligible for autostart'
part of the query. We achieve this by calculating the 'next start at'
time of the workspace, storing it in the database, and using it in our
`GetWorkspacesEligibleForTransition` query.

The prior implementation of the 'is eligible for autostart' query would
return _all_ workspaces that at some point in the future _might_ be
eligible for autostart. This now ensures we only return workspaces that
_should_ be eligible for autostart.

We also now pass `currentTick` instead of `t` to the
`GetWorkspacesEligibleForTransition` query as otherwise we'll have one
round of workspaces that are skipped by `isEligibleForTransition` due to
`currentTick` being a truncated version of `t`.
This commit is contained in:
Danielle Maywood
2024-12-02 21:02:36 +00:00
committed by GitHub
parent 2b57dcc68c
commit e21a301682
35 changed files with 1012 additions and 75 deletions
+2 -1
View File
@@ -65,6 +65,7 @@
},
"automatic_updates": "never",
"allow_renames": false,
"favorite": false
"favorite": false,
"next_start_at": "[timestamp]"
}
]
+4
View File
@@ -14543,6 +14543,10 @@ const docTemplate = `{
"name": {
"type": "string"
},
"next_start_at": {
"type": "string",
"format": "date-time"
},
"organization_id": {
"type": "string",
"format": "uuid"
+4
View File
@@ -13218,6 +13218,10 @@
"name": {
"type": "string"
},
"next_start_at": {
"type": "string",
"format": "date-time"
},
"organization_id": {
"type": "string",
"format": "uuid"
+20 -3
View File
@@ -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
}
+7 -1
View File
@@ -1083,6 +1083,10 @@ func TestNotifications(t *testing.T) {
IncludeProvisionerDaemon: true,
NotificationsEnqueuer: &notifyEnq,
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)
+21
View File
@@ -1053,6 +1053,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
@@ -2840,6 +2847,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)
}
@@ -4085,6 +4099,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)
+16
View File
@@ -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()
}))
+1
View File
@@ -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
+77 -2
View File
@@ -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()
+21
View File
@@ -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)
+43
View File
@@ -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()
+28 -1
View File
@@ -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();
@@ -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.';
@@ -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.';
+2
View File
@@ -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,
}
}
+1
View File
@@ -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,
+3 -1
View File
@@ -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"`
}
+3
View File
@@ -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
+137 -17
View File
@@ -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
}
+47 -4
View File
@@ -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;
@@ -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,
+20
View File
@@ -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
}
+41
View File
@@ -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)
})
}
+25
View File
@@ -30,6 +30,7 @@ import (
"github.com/coder/coder/v2/coderd/provisionerdserver"
"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"
@@ -555,6 +556,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{
@@ -620,6 +629,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!
@@ -881,9 +891,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{
@@ -1903,6 +1922,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 {
@@ -1953,6 +1977,7 @@ func convertWorkspace(
AutomaticUpdates: codersdk.AutomaticUpdates(workspace.AutomaticUpdates),
AllowRenames: allowRenames,
Favorite: requesterFavorite,
NextStartAt: nextStartAt,
}, nil
}
+1
View File
@@ -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 {
+1 -1
View File
@@ -28,7 +28,7 @@ We track the following resources:
| User<br><i>create, write, delete</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>avatar_url</td><td>false</td></tr><tr><td>created_at</td><td>false</td></tr><tr><td>deleted</td><td>true</td></tr><tr><td>email</td><td>true</td></tr><tr><td>github_com_user_id</td><td>false</td></tr><tr><td>hashed_one_time_passcode</td><td>false</td></tr><tr><td>hashed_password</td><td>true</td></tr><tr><td>id</td><td>true</td></tr><tr><td>last_seen_at</td><td>false</td></tr><tr><td>login_type</td><td>true</td></tr><tr><td>name</td><td>true</td></tr><tr><td>one_time_passcode_expires_at</td><td>true</td></tr><tr><td>quiet_hours_schedule</td><td>true</td></tr><tr><td>rbac_roles</td><td>true</td></tr><tr><td>status</td><td>true</td></tr><tr><td>theme_preference</td><td>false</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>username</td><td>true</td></tr></tbody></table> |
| WorkspaceBuild<br><i>start, stop</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>build_number</td><td>false</td></tr><tr><td>created_at</td><td>false</td></tr><tr><td>daily_cost</td><td>false</td></tr><tr><td>deadline</td><td>false</td></tr><tr><td>id</td><td>false</td></tr><tr><td>initiator_by_avatar_url</td><td>false</td></tr><tr><td>initiator_by_username</td><td>false</td></tr><tr><td>initiator_id</td><td>false</td></tr><tr><td>job_id</td><td>false</td></tr><tr><td>max_deadline</td><td>false</td></tr><tr><td>provisioner_state</td><td>false</td></tr><tr><td>reason</td><td>false</td></tr><tr><td>template_version_id</td><td>true</td></tr><tr><td>transition</td><td>false</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>workspace_id</td><td>false</td></tr></tbody></table> |
| WorkspaceProxy<br><i></i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>created_at</td><td>true</td></tr><tr><td>deleted</td><td>false</td></tr><tr><td>derp_enabled</td><td>true</td></tr><tr><td>derp_only</td><td>true</td></tr><tr><td>display_name</td><td>true</td></tr><tr><td>icon</td><td>true</td></tr><tr><td>id</td><td>true</td></tr><tr><td>name</td><td>true</td></tr><tr><td>region_id</td><td>true</td></tr><tr><td>token_hashed_secret</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>url</td><td>true</td></tr><tr><td>version</td><td>true</td></tr><tr><td>wildcard_hostname</td><td>true</td></tr></tbody></table> |
| WorkspaceTable<br><i></i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>automatic_updates</td><td>true</td></tr><tr><td>autostart_schedule</td><td>true</td></tr><tr><td>created_at</td><td>false</td></tr><tr><td>deleted</td><td>false</td></tr><tr><td>deleting_at</td><td>true</td></tr><tr><td>dormant_at</td><td>true</td></tr><tr><td>favorite</td><td>true</td></tr><tr><td>id</td><td>true</td></tr><tr><td>last_used_at</td><td>false</td></tr><tr><td>name</td><td>true</td></tr><tr><td>organization_id</td><td>false</td></tr><tr><td>owner_id</td><td>true</td></tr><tr><td>template_id</td><td>true</td></tr><tr><td>ttl</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr></tbody></table> |
| WorkspaceTable<br><i></i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>automatic_updates</td><td>true</td></tr><tr><td>autostart_schedule</td><td>true</td></tr><tr><td>created_at</td><td>false</td></tr><tr><td>deleted</td><td>false</td></tr><tr><td>deleting_at</td><td>true</td></tr><tr><td>dormant_at</td><td>true</td></tr><tr><td>favorite</td><td>true</td></tr><tr><td>id</td><td>true</td></tr><tr><td>last_used_at</td><td>false</td></tr><tr><td>name</td><td>true</td></tr><tr><td>next_start_at</td><td>true</td></tr><tr><td>organization_id</td><td>false</td></tr><tr><td>owner_id</td><td>true</td></tr><tr><td>template_id</td><td>true</td></tr><tr><td>ttl</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr></tbody></table> |
<!-- End generated by 'make docs/admin/security/audit-logs.md'. -->
+3
View File
@@ -6733,6 +6733,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,
@@ -6767,6 +6768,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 | | |
@@ -8064,6 +8066,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,
+6
View File
@@ -222,6 +222,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,
@@ -445,6 +446,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,
@@ -692,6 +694,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,
@@ -914,6 +917,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,
@@ -1138,6 +1142,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,
@@ -1477,6 +1482,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,
+1
View File
@@ -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,
+1 -1
View File
@@ -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)
+66 -9
View File
@@ -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".
+10 -10
View File
@@ -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, &notifyEnq, logger)
templateScheduleStore.TimeNowFn = time.Now
templateScheduleStore := schedule.NewEnterpriseTemplateScheduleStore(userQuietHoursStorePtr, &notifyEnq, logger, nil)
// Lower the dormancy TTL to ensure the schedule recalculates deadlines and
// triggers notifications.
+2 -2
View File
@@ -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{
+265 -21
View File
@@ -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{
+1
View File
@@ -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