Skip to content

Commit 0d9615b

Browse files
feat(coderd): notify when workspace is marked as dormant (coder#13868)
1 parent ccb5b4d commit 0d9615b

25 files changed

+650
-118
lines changed

coderd/autobuild/lifecycle_executor.go

Lines changed: 28 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import (
1919
"github.com/coder/coder/v2/coderd/database/dbtime"
2020
"github.com/coder/coder/v2/coderd/database/provisionerjobs"
2121
"github.com/coder/coder/v2/coderd/database/pubsub"
22+
"github.com/coder/coder/v2/coderd/dormancy"
2223
"github.com/coder/coder/v2/coderd/notifications"
2324
"github.com/coder/coder/v2/coderd/schedule"
2425
"github.com/coder/coder/v2/coderd/wsbuilder"
@@ -35,7 +36,6 @@ type Executor struct {
3536
log slog.Logger
3637
tick <-chan time.Time
3738
statsCh chan<- Stats
38-
3939
// NotificationsEnqueuer handles enqueueing notifications for delivery by SMTP, webhook, etc.
4040
notificationsEnqueuer notifications.Enqueuer
4141
}
@@ -142,13 +142,15 @@ func (e *Executor) runOnce(t time.Time) Stats {
142142

143143
eg.Go(func() error {
144144
err := func() error {
145-
var job *database.ProvisionerJob
146-
var nextBuild *database.WorkspaceBuild
147-
var activeTemplateVersion database.TemplateVersion
148-
var ws database.Workspace
149-
150-
var auditLog *auditParams
151-
var didAutoUpdate bool
145+
var (
146+
job *database.ProvisionerJob
147+
auditLog *auditParams
148+
dormantNotification *dormancy.WorkspaceDormantNotification
149+
nextBuild *database.WorkspaceBuild
150+
activeTemplateVersion database.TemplateVersion
151+
ws database.Workspace
152+
didAutoUpdate bool
153+
)
152154
err := e.db.InTx(func(tx database.Store) error {
153155
var err error
154156

@@ -246,6 +248,13 @@ func (e *Executor) runOnce(t time.Time) Stats {
246248
return xerrors.Errorf("update workspace dormant deleting at: %w", err)
247249
}
248250

251+
dormantNotification = &dormancy.WorkspaceDormantNotification{
252+
Workspace: ws,
253+
Initiator: "autobuild",
254+
Reason: "breached the template's threshold for inactivity",
255+
CreatedBy: "lifecycleexecutor",
256+
}
257+
249258
log.Info(e.ctx, "dormant workspace",
250259
slog.F("last_used_at", ws.LastUsedAt),
251260
slog.F("time_til_dormant", templateSchedule.TimeTilDormant),
@@ -290,7 +299,7 @@ func (e *Executor) runOnce(t time.Time) Stats {
290299
nextBuildReason = string(nextBuild.Reason)
291300
}
292301

293-
if _, err := e.notificationsEnqueuer.Enqueue(e.ctx, ws.OwnerID, notifications.WorkspaceAutoUpdated,
302+
if _, err := e.notificationsEnqueuer.Enqueue(e.ctx, ws.OwnerID, notifications.TemplateWorkspaceAutoUpdated,
294303
map[string]string{
295304
"name": ws.Name,
296305
"initiator": "autobuild",
@@ -316,6 +325,16 @@ func (e *Executor) runOnce(t time.Time) Stats {
316325
return xerrors.Errorf("post provisioner job to pubsub: %w", err)
317326
}
318327
}
328+
if dormantNotification != nil {
329+
_, err = dormancy.NotifyWorkspaceDormant(
330+
e.ctx,
331+
e.notificationsEnqueuer,
332+
*dormantNotification,
333+
)
334+
if err != nil {
335+
log.Warn(e.ctx, "failed to notify of workspace marked as dormant", slog.Error(err), slog.F("workspace_id", dormantNotification.Workspace.ID))
336+
}
337+
}
319338
return nil
320339
}()
321340
if err != nil {

coderd/autobuild/lifecycle_executor_test.go

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import (
1818
"github.com/coder/coder/v2/coderd/coderdtest"
1919
"github.com/coder/coder/v2/coderd/database"
2020
"github.com/coder/coder/v2/coderd/database/dbauthz"
21+
"github.com/coder/coder/v2/coderd/notifications"
2122
"github.com/coder/coder/v2/coderd/schedule"
2223
"github.com/coder/coder/v2/coderd/schedule/cron"
2324
"github.com/coder/coder/v2/coderd/util/ptr"
@@ -115,7 +116,7 @@ func TestExecutorAutostartTemplateUpdated(t *testing.T) {
115116
tickCh = make(chan time.Time)
116117
statsCh = make(chan autobuild.Stats)
117118
logger = slogtest.Make(t, &slogtest.Options{IgnoreErrors: !tc.expectStart}).Leveled(slog.LevelDebug)
118-
enqueuer = testutil.FakeNotificationEnqueuer{}
119+
enqueuer = testutil.FakeNotificationsEnqueuer{}
119120
client = coderdtest.New(t, &coderdtest.Options{
120121
AutobuildTicker: tickCh,
121122
IncludeProvisionerDaemon: true,
@@ -1062,6 +1063,69 @@ func TestExecutorInactiveWorkspace(t *testing.T) {
10621063
})
10631064
}
10641065

1066+
func TestNotifications(t *testing.T) {
1067+
t.Parallel()
1068+
1069+
t.Run("Dormancy", func(t *testing.T) {
1070+
t.Parallel()
1071+
1072+
// Setup template with dormancy and create a workspace with it
1073+
var (
1074+
ticker = make(chan time.Time)
1075+
statCh = make(chan autobuild.Stats)
1076+
notifyEnq = testutil.FakeNotificationsEnqueuer{}
1077+
timeTilDormant = time.Minute
1078+
client = coderdtest.New(t, &coderdtest.Options{
1079+
AutobuildTicker: ticker,
1080+
AutobuildStats: statCh,
1081+
IncludeProvisionerDaemon: true,
1082+
NotificationsEnqueuer: &notifyEnq,
1083+
TemplateScheduleStore: schedule.MockTemplateScheduleStore{
1084+
GetFn: func(_ context.Context, _ database.Store, _ uuid.UUID) (schedule.TemplateScheduleOptions, error) {
1085+
return schedule.TemplateScheduleOptions{
1086+
UserAutostartEnabled: false,
1087+
UserAutostopEnabled: true,
1088+
DefaultTTL: 0,
1089+
AutostopRequirement: schedule.TemplateAutostopRequirement{},
1090+
TimeTilDormant: timeTilDormant,
1091+
}, nil
1092+
},
1093+
},
1094+
})
1095+
admin = coderdtest.CreateFirstUser(t, client)
1096+
version = coderdtest.CreateTemplateVersion(t, client, admin.OrganizationID, nil)
1097+
)
1098+
1099+
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
1100+
template := coderdtest.CreateTemplate(t, client, admin.OrganizationID, version.ID)
1101+
userClient, _ := coderdtest.CreateAnotherUser(t, client, admin.OrganizationID)
1102+
workspace := coderdtest.CreateWorkspace(t, userClient, admin.OrganizationID, template.ID)
1103+
coderdtest.AwaitWorkspaceBuildJobCompleted(t, userClient, workspace.LatestBuild.ID)
1104+
1105+
// Stop workspace
1106+
workspace = coderdtest.MustTransitionWorkspace(t, client, workspace.ID, database.WorkspaceTransitionStart, database.WorkspaceTransitionStop)
1107+
_ = coderdtest.AwaitWorkspaceBuildJobCompleted(t, userClient, workspace.LatestBuild.ID)
1108+
1109+
// Wait for workspace to become dormant
1110+
ticker <- workspace.LastUsedAt.Add(timeTilDormant * 3)
1111+
_ = testutil.RequireRecvCtx(testutil.Context(t, testutil.WaitShort), t, statCh)
1112+
1113+
// Check that the workspace is dormant
1114+
workspace = coderdtest.MustWorkspace(t, client, workspace.ID)
1115+
require.NotNil(t, workspace.DormantAt)
1116+
1117+
// Check that a notification was enqueued
1118+
require.Len(t, notifyEnq.Sent, 1)
1119+
require.Equal(t, notifyEnq.Sent[0].UserID, workspace.OwnerID)
1120+
require.Equal(t, notifyEnq.Sent[0].TemplateID, notifications.TemplateWorkspaceDormant)
1121+
require.Contains(t, notifyEnq.Sent[0].Targets, template.ID)
1122+
require.Contains(t, notifyEnq.Sent[0].Targets, workspace.ID)
1123+
require.Contains(t, notifyEnq.Sent[0].Targets, workspace.OrganizationID)
1124+
require.Contains(t, notifyEnq.Sent[0].Targets, workspace.OwnerID)
1125+
require.Equal(t, notifyEnq.Sent[0].Labels["initiator"], "autobuild")
1126+
})
1127+
}
1128+
10651129
func mustProvisionWorkspace(t *testing.T, client *codersdk.Client, mut ...func(*codersdk.CreateWorkspaceRequest)) codersdk.Workspace {
10661130
t.Helper()
10671131
user := coderdtest.CreateFirstUser(t, client)

coderd/coderdtest/coderdtest.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -242,7 +242,7 @@ func NewOptions(t testing.TB, options *Options) (func(http.Handler), context.Can
242242
}
243243

244244
if options.NotificationsEnqueuer == nil {
245-
options.NotificationsEnqueuer = new(testutil.FakeNotificationEnqueuer)
245+
options.NotificationsEnqueuer = new(testutil.FakeNotificationsEnqueuer)
246246
}
247247

248248
accessControlStore := &atomic.Pointer[dbauthz.AccessControlStore]{}
@@ -289,6 +289,9 @@ func NewOptions(t testing.TB, options *Options) (func(http.Handler), context.Can
289289
options.StatsBatcher = batcher
290290
t.Cleanup(closeBatcher)
291291
}
292+
if options.NotificationsEnqueuer == nil {
293+
options.NotificationsEnqueuer = &testutil.FakeNotificationsEnqueuer{}
294+
}
292295

293296
var templateScheduleStore atomic.Pointer[schedule.TemplateScheduleStore]
294297
if options.TemplateScheduleStore == nil {

coderd/database/dbauthz/dbauthz.go

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3555,12 +3555,15 @@ func (q *querier) UpdateWorkspaceTTL(ctx context.Context, arg database.UpdateWor
35553555
return update(q.log, q.auth, fetch, q.db.UpdateWorkspaceTTL)(ctx, arg)
35563556
}
35573557

3558-
func (q *querier) UpdateWorkspacesDormantDeletingAtByTemplateID(ctx context.Context, arg database.UpdateWorkspacesDormantDeletingAtByTemplateIDParams) error {
3559-
fetch := func(ctx context.Context, arg database.UpdateWorkspacesDormantDeletingAtByTemplateIDParams) (database.Template, error) {
3560-
return q.db.GetTemplateByID(ctx, arg.TemplateID)
3558+
func (q *querier) UpdateWorkspacesDormantDeletingAtByTemplateID(ctx context.Context, arg database.UpdateWorkspacesDormantDeletingAtByTemplateIDParams) ([]database.Workspace, error) {
3559+
template, err := q.db.GetTemplateByID(ctx, arg.TemplateID)
3560+
if err != nil {
3561+
return nil, xerrors.Errorf("get template by id: %w", err)
35613562
}
3562-
3563-
return fetchAndExec(q.log, q.auth, policy.ActionUpdate, fetch, q.db.UpdateWorkspacesDormantDeletingAtByTemplateID)(ctx, arg)
3563+
if err := q.authorizeContext(ctx, policy.ActionUpdate, template); err != nil {
3564+
return nil, err
3565+
}
3566+
return q.db.UpdateWorkspacesDormantDeletingAtByTemplateID(ctx, arg)
35643567
}
35653568

35663569
func (q *querier) UpsertAnnouncementBanners(ctx context.Context, value string) error {

coderd/database/dbmem/dbmem.go

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8700,15 +8700,16 @@ func (q *FakeQuerier) UpdateWorkspaceTTL(_ context.Context, arg database.UpdateW
87008700
return sql.ErrNoRows
87018701
}
87028702

8703-
func (q *FakeQuerier) UpdateWorkspacesDormantDeletingAtByTemplateID(_ context.Context, arg database.UpdateWorkspacesDormantDeletingAtByTemplateIDParams) error {
8703+
func (q *FakeQuerier) UpdateWorkspacesDormantDeletingAtByTemplateID(_ context.Context, arg database.UpdateWorkspacesDormantDeletingAtByTemplateIDParams) ([]database.Workspace, error) {
87048704
q.mutex.Lock()
87058705
defer q.mutex.Unlock()
87068706

87078707
err := validateDatabaseType(arg)
87088708
if err != nil {
8709-
return err
8709+
return nil, err
87108710
}
87118711

8712+
affectedRows := []database.Workspace{}
87128713
for i, ws := range q.workspaces {
87138714
if ws.TemplateID != arg.TemplateID {
87148715
continue
@@ -8733,9 +8734,10 @@ func (q *FakeQuerier) UpdateWorkspacesDormantDeletingAtByTemplateID(_ context.Co
87338734
}
87348735
ws.DeletingAt = deletingAt
87358736
q.workspaces[i] = ws
8737+
affectedRows = append(affectedRows, ws)
87368738
}
87378739

8738-
return nil
8740+
return affectedRows, nil
87398741
}
87408742

87418743
func (q *FakeQuerier) UpsertAnnouncementBanners(_ context.Context, data string) error {

coderd/database/dbmetrics/dbmetrics.go

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

coderd/database/dbmock/dbmock.go

Lines changed: 4 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
DELETE FROM notification_templates
2+
WHERE
3+
id = '0ea69165-ec14-4314-91f1-69566ac3c5a0';
4+
5+
DELETE FROM notification_templates
6+
WHERE
7+
id = '51ce2fdf-c9ca-4be1-8d70-628674f9bc42';
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
INSERT INTO
2+
notification_templates (
3+
id,
4+
name,
5+
title_template,
6+
body_template,
7+
"group",
8+
actions
9+
)
10+
VALUES (
11+
'0ea69165-ec14-4314-91f1-69566ac3c5a0',
12+
'Workspace Marked as Dormant',
13+
E'Workspace "{{.Labels.name}}" marked as dormant',
14+
E'Hi {{.UserName}}\n\n' || E'Your workspace **{{.Labels.name}}** has been marked as **dormant**.\n' || E'The specified reason was "**{{.Labels.reason}} (initiated by: {{ .Labels.initiator }}){{end}}**\n\n' || E'Dormancy refers to a workspace being unused for a defined length of time, and after it exceeds {{.Labels.dormancyHours}} hours of dormancy might be deleted.\n' || E'To activate your workspace again, simply use it as normal.',
15+
'Workspace Events',
16+
'[
17+
{
18+
"label": "View workspace",
19+
"url": "{{ base_url }}/@{{.UserName}}/{{.Labels.name}}"
20+
}
21+
]'::jsonb
22+
),
23+
(
24+
'51ce2fdf-c9ca-4be1-8d70-628674f9bc42',
25+
'Workspace Marked for Deletion',
26+
E'Workspace "{{.Labels.name}}" marked for deletion',
27+
E'Hi {{.UserName}}\n\n' || E'Your workspace **{{.Labels.name}}** has been marked for **deletion** after {{.Labels.dormancyHours}} hours of dormancy.\n' || E'The specified reason was "**{{.Labels.reason}}{{end}}**\n\n' || E'Dormancy refers to a workspace being unused for a defined length of time, and after it exceeds {{.Labels.dormancyHours}} hours of dormancy it will be deleted.\n' || E'To prevent your workspace from being deleted, simply use it as normal.',
28+
'Workspace Events',
29+
'[
30+
{
31+
"label": "View workspace",
32+
"url": "{{ base_url }}/@{{.UserName}}/{{.Labels.name}}"
33+
}
34+
]'::jsonb
35+
);

coderd/database/querier.go

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

coderd/database/queries.sql.go

Lines changed: 39 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)