Thanks to visit codestin.com
Credit goes to github.com

Skip to content

feat(coderd): notify when workspace is marked as dormant #13868

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 44 commits into from
Jul 24, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
9a398e7
feat(coderd): notify when workspace is marked as dormant
BrunoQuaresma Jul 10, 2024
fbf99be
Fix role
BrunoQuaresma Jul 10, 2024
40e5801
Apply Danny suggestions
BrunoQuaresma Jul 11, 2024
9a35cbb
Notify dormant workspace on lifecycle executor
BrunoQuaresma Jul 11, 2024
0701572
Notify dormancy on template schedule
BrunoQuaresma Jul 11, 2024
64cf76b
Apply Danny review suggestions
BrunoQuaresma Jul 12, 2024
b2f9180
Fix mismatch usage
BrunoQuaresma Jul 12, 2024
a445c4c
Resolve migration conflict
BrunoQuaresma Jul 12, 2024
bdc08d6
Merge branch 'main' of https://github.com/coder/coder into bq/impleme…
BrunoQuaresma Jul 12, 2024
ee7d542
Return error instead of receiving the logger
BrunoQuaresma Jul 12, 2024
f6db3c7
Improve verbiage
BrunoQuaresma Jul 12, 2024
95c784a
Merge branch 'main' of github.com:/coder/coder into bq/implement-noti…
dannykopping Jul 15, 2024
68bcfb0
Merge branch 'main' of github.com:/coder/coder into bq/implement-noti…
dannykopping Jul 16, 2024
467a797
Possible implementation simplification
dannykopping Jul 16, 2024
853e59e
Merge branch 'main' of https://github.com/coder/coder into bq/impleme…
BrunoQuaresma Jul 17, 2024
bbcb28e
Apply fmt
BrunoQuaresma Jul 17, 2024
e3c6f49
Notify after executor is done
BrunoQuaresma Jul 17, 2024
20a8766
Revert refactoring mistake
dannykopping Jul 18, 2024
e087172
Add workspace name to the log
BrunoQuaresma Jul 18, 2024
4b99061
Set a fake enqueuer on coderdtest options
BrunoQuaresma Jul 18, 2024
1932168
Merge branch 'bq/implement-notifications' of https://github.com/coder…
BrunoQuaresma Jul 18, 2024
36c043c
Fix migration
BrunoQuaresma Jul 18, 2024
d07f9d8
Fix migration sql
BrunoQuaresma Jul 18, 2024
21f7c35
Merge branch 'main' of https://github.com/coder/coder into bq/impleme…
BrunoQuaresma Jul 18, 2024
72da82d
Fix typo
BrunoQuaresma Jul 18, 2024
b49cd08
Fix migration number
BrunoQuaresma Jul 18, 2024
6f61f9f
Merge branch 'main' of https://github.com/coder/coder into bq/impleme…
BrunoQuaresma Jul 18, 2024
281b545
Apply Marcin comments
BrunoQuaresma Jul 18, 2024
7f73d90
Fix lint
BrunoQuaresma Jul 18, 2024
7c5de97
Update coderd/autobuild/lifecycle_executor.go
BrunoQuaresma Jul 18, 2024
299cd7f
Apply dannys comment
BrunoQuaresma Jul 18, 2024
16a3c19
Simplify dormancy template
BrunoQuaresma Jul 22, 2024
cf9eaec
Merge branch 'main' of https://github.com/coder/coder into bq/impleme…
BrunoQuaresma Jul 22, 2024
aea55aa
Add test to verify dormancy in lifecycle executor
BrunoQuaresma Jul 22, 2024
6d47c04
Add placeholder
BrunoQuaresma Jul 22, 2024
7b4ae29
make test pass
sreya Jul 23, 2024
308bd69
fix: lint
mtojek Jul 23, 2024
10ff6cf
Merge branch 'main' of https://github.com/coder/coder into bq/impleme…
BrunoQuaresma Jul 23, 2024
48ad269
Add test to verify notification in lifecycle executor
BrunoQuaresma Jul 23, 2024
0226fdf
Add notification for marked as deletion
BrunoQuaresma Jul 23, 2024
4d71c94
Apply Dannys review comments
BrunoQuaresma Jul 24, 2024
e1d5fec
Fix SQL
BrunoQuaresma Jul 24, 2024
a2023a1
Rollback dormant at test
BrunoQuaresma Jul 24, 2024
bae985c
Fix template
BrunoQuaresma Jul 24, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 28 additions & 9 deletions coderd/autobuild/lifecycle_executor.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import (
"github.com/coder/coder/v2/coderd/database/dbtime"
"github.com/coder/coder/v2/coderd/database/provisionerjobs"
"github.com/coder/coder/v2/coderd/database/pubsub"
"github.com/coder/coder/v2/coderd/dormancy"
"github.com/coder/coder/v2/coderd/notifications"
"github.com/coder/coder/v2/coderd/schedule"
"github.com/coder/coder/v2/coderd/wsbuilder"
Expand All @@ -35,7 +36,6 @@ type Executor struct {
log slog.Logger
tick <-chan time.Time
statsCh chan<- Stats

// NotificationsEnqueuer handles enqueueing notifications for delivery by SMTP, webhook, etc.
notificationsEnqueuer notifications.Enqueuer
}
Expand Down Expand Up @@ -142,13 +142,15 @@ func (e *Executor) runOnce(t time.Time) Stats {

eg.Go(func() error {
err := func() error {
var job *database.ProvisionerJob
var nextBuild *database.WorkspaceBuild
var activeTemplateVersion database.TemplateVersion
var ws database.Workspace

var auditLog *auditParams
var didAutoUpdate bool
var (
job *database.ProvisionerJob
auditLog *auditParams
dormantNotification *dormancy.WorkspaceDormantNotification
nextBuild *database.WorkspaceBuild
activeTemplateVersion database.TemplateVersion
ws database.Workspace
didAutoUpdate bool
)
err := e.db.InTx(func(tx database.Store) error {
var err error

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

dormantNotification = &dormancy.WorkspaceDormantNotification{
Workspace: ws,
Initiator: "autobuild",
Reason: "breached the template's threshold for inactivity",
CreatedBy: "lifecycleexecutor",
}

log.Info(e.ctx, "dormant workspace",
slog.F("last_used_at", ws.LastUsedAt),
slog.F("time_til_dormant", templateSchedule.TimeTilDormant),
Expand Down Expand Up @@ -290,7 +299,7 @@ func (e *Executor) runOnce(t time.Time) Stats {
nextBuildReason = string(nextBuild.Reason)
}

if _, err := e.notificationsEnqueuer.Enqueue(e.ctx, ws.OwnerID, notifications.WorkspaceAutoUpdated,
if _, err := e.notificationsEnqueuer.Enqueue(e.ctx, ws.OwnerID, notifications.TemplateWorkspaceAutoUpdated,
map[string]string{
"name": ws.Name,
"initiator": "autobuild",
Expand All @@ -316,6 +325,16 @@ func (e *Executor) runOnce(t time.Time) Stats {
return xerrors.Errorf("post provisioner job to pubsub: %w", err)
}
}
if dormantNotification != nil {
_, err = dormancy.NotifyWorkspaceDormant(
e.ctx,
e.notificationsEnqueuer,
*dormantNotification,
)
if err != nil {
log.Warn(e.ctx, "failed to notify of workspace marked as dormant", slog.Error(err), slog.F("workspace_id", dormantNotification.Workspace.ID))
}
}
return nil
}()
if err != nil {
Expand Down
66 changes: 65 additions & 1 deletion coderd/autobuild/lifecycle_executor_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import (
"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/notifications"
"github.com/coder/coder/v2/coderd/schedule"
"github.com/coder/coder/v2/coderd/schedule/cron"
"github.com/coder/coder/v2/coderd/util/ptr"
Expand Down Expand Up @@ -115,7 +116,7 @@ func TestExecutorAutostartTemplateUpdated(t *testing.T) {
tickCh = make(chan time.Time)
statsCh = make(chan autobuild.Stats)
logger = slogtest.Make(t, &slogtest.Options{IgnoreErrors: !tc.expectStart}).Leveled(slog.LevelDebug)
enqueuer = testutil.FakeNotificationEnqueuer{}
enqueuer = testutil.FakeNotificationsEnqueuer{}
client = coderdtest.New(t, &coderdtest.Options{
AutobuildTicker: tickCh,
IncludeProvisionerDaemon: true,
Expand Down Expand Up @@ -1062,6 +1063,69 @@ func TestExecutorInactiveWorkspace(t *testing.T) {
})
}

func TestNotifications(t *testing.T) {
t.Parallel()

t.Run("Dormancy", func(t *testing.T) {
t.Parallel()

// Setup template with dormancy and create a workspace with it
var (
ticker = make(chan time.Time)
statCh = make(chan autobuild.Stats)
notifyEnq = testutil.FakeNotificationsEnqueuer{}
timeTilDormant = time.Minute
client = coderdtest.New(t, &coderdtest.Options{
AutobuildTicker: ticker,
AutobuildStats: statCh,
IncludeProvisionerDaemon: true,
NotificationsEnqueuer: &notifyEnq,
TemplateScheduleStore: schedule.MockTemplateScheduleStore{
GetFn: func(_ context.Context, _ database.Store, _ uuid.UUID) (schedule.TemplateScheduleOptions, error) {
return schedule.TemplateScheduleOptions{
UserAutostartEnabled: false,
UserAutostopEnabled: true,
DefaultTTL: 0,
AutostopRequirement: schedule.TemplateAutostopRequirement{},
TimeTilDormant: timeTilDormant,
}, nil
},
},
})
admin = coderdtest.CreateFirstUser(t, client)
version = coderdtest.CreateTemplateVersion(t, client, admin.OrganizationID, nil)
)

coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
template := coderdtest.CreateTemplate(t, client, admin.OrganizationID, version.ID)
userClient, _ := coderdtest.CreateAnotherUser(t, client, admin.OrganizationID)
workspace := coderdtest.CreateWorkspace(t, userClient, admin.OrganizationID, template.ID)
coderdtest.AwaitWorkspaceBuildJobCompleted(t, userClient, workspace.LatestBuild.ID)

// Stop workspace
workspace = coderdtest.MustTransitionWorkspace(t, client, workspace.ID, database.WorkspaceTransitionStart, database.WorkspaceTransitionStop)
_ = coderdtest.AwaitWorkspaceBuildJobCompleted(t, userClient, workspace.LatestBuild.ID)

// Wait for workspace to become dormant
ticker <- workspace.LastUsedAt.Add(timeTilDormant * 3)
_ = testutil.RequireRecvCtx(testutil.Context(t, testutil.WaitShort), t, statCh)

// Check that the workspace is dormant
workspace = coderdtest.MustWorkspace(t, client, workspace.ID)
require.NotNil(t, workspace.DormantAt)

// Check that a notification was enqueued
require.Len(t, notifyEnq.Sent, 1)
require.Equal(t, notifyEnq.Sent[0].UserID, workspace.OwnerID)
require.Equal(t, notifyEnq.Sent[0].TemplateID, notifications.TemplateWorkspaceDormant)
require.Contains(t, notifyEnq.Sent[0].Targets, template.ID)
require.Contains(t, notifyEnq.Sent[0].Targets, workspace.ID)
require.Contains(t, notifyEnq.Sent[0].Targets, workspace.OrganizationID)
require.Contains(t, notifyEnq.Sent[0].Targets, workspace.OwnerID)
require.Equal(t, notifyEnq.Sent[0].Labels["initiator"], "autobuild")
})
}

func mustProvisionWorkspace(t *testing.T, client *codersdk.Client, mut ...func(*codersdk.CreateWorkspaceRequest)) codersdk.Workspace {
t.Helper()
user := coderdtest.CreateFirstUser(t, client)
Expand Down
5 changes: 4 additions & 1 deletion coderd/coderdtest/coderdtest.go
Original file line number Diff line number Diff line change
Expand Up @@ -242,7 +242,7 @@ func NewOptions(t testing.TB, options *Options) (func(http.Handler), context.Can
}

if options.NotificationsEnqueuer == nil {
options.NotificationsEnqueuer = new(testutil.FakeNotificationEnqueuer)
options.NotificationsEnqueuer = new(testutil.FakeNotificationsEnqueuer)
}

accessControlStore := &atomic.Pointer[dbauthz.AccessControlStore]{}
Expand Down Expand Up @@ -289,6 +289,9 @@ func NewOptions(t testing.TB, options *Options) (func(http.Handler), context.Can
options.StatsBatcher = batcher
t.Cleanup(closeBatcher)
}
if options.NotificationsEnqueuer == nil {
options.NotificationsEnqueuer = &testutil.FakeNotificationsEnqueuer{}
}

var templateScheduleStore atomic.Pointer[schedule.TemplateScheduleStore]
if options.TemplateScheduleStore == nil {
Expand Down
13 changes: 8 additions & 5 deletions coderd/database/dbauthz/dbauthz.go
Original file line number Diff line number Diff line change
Expand Up @@ -3555,12 +3555,15 @@ func (q *querier) UpdateWorkspaceTTL(ctx context.Context, arg database.UpdateWor
return update(q.log, q.auth, fetch, q.db.UpdateWorkspaceTTL)(ctx, arg)
}

func (q *querier) UpdateWorkspacesDormantDeletingAtByTemplateID(ctx context.Context, arg database.UpdateWorkspacesDormantDeletingAtByTemplateIDParams) error {
fetch := func(ctx context.Context, arg database.UpdateWorkspacesDormantDeletingAtByTemplateIDParams) (database.Template, error) {
return q.db.GetTemplateByID(ctx, arg.TemplateID)
func (q *querier) UpdateWorkspacesDormantDeletingAtByTemplateID(ctx context.Context, arg database.UpdateWorkspacesDormantDeletingAtByTemplateIDParams) ([]database.Workspace, error) {
template, err := q.db.GetTemplateByID(ctx, arg.TemplateID)
if err != nil {
return nil, xerrors.Errorf("get template by id: %w", err)
}

return fetchAndExec(q.log, q.auth, policy.ActionUpdate, fetch, q.db.UpdateWorkspacesDormantDeletingAtByTemplateID)(ctx, arg)
if err := q.authorizeContext(ctx, policy.ActionUpdate, template); err != nil {
return nil, err
}
return q.db.UpdateWorkspacesDormantDeletingAtByTemplateID(ctx, arg)
}

func (q *querier) UpsertAnnouncementBanners(ctx context.Context, value string) error {
Expand Down
8 changes: 5 additions & 3 deletions coderd/database/dbmem/dbmem.go
Original file line number Diff line number Diff line change
Expand Up @@ -8700,15 +8700,16 @@ func (q *FakeQuerier) UpdateWorkspaceTTL(_ context.Context, arg database.UpdateW
return sql.ErrNoRows
}

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

err := validateDatabaseType(arg)
if err != nil {
return err
return nil, err
}

affectedRows := []database.Workspace{}
for i, ws := range q.workspaces {
if ws.TemplateID != arg.TemplateID {
continue
Expand All @@ -8733,9 +8734,10 @@ func (q *FakeQuerier) UpdateWorkspacesDormantDeletingAtByTemplateID(_ context.Co
}
ws.DeletingAt = deletingAt
q.workspaces[i] = ws
affectedRows = append(affectedRows, ws)
}

return nil
return affectedRows, nil
}

func (q *FakeQuerier) UpsertAnnouncementBanners(_ context.Context, data string) error {
Expand Down
6 changes: 3 additions & 3 deletions coderd/database/dbmetrics/dbmetrics.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 4 additions & 3 deletions coderd/database/dbmock/dbmock.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
DELETE FROM notification_templates
WHERE
id = '0ea69165-ec14-4314-91f1-69566ac3c5a0';

DELETE FROM notification_templates
WHERE
id = '51ce2fdf-c9ca-4be1-8d70-628674f9bc42';
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
INSERT INTO
notification_templates (
id,
name,
title_template,
body_template,
"group",
actions
)
VALUES (
'0ea69165-ec14-4314-91f1-69566ac3c5a0',
'Workspace Marked as Dormant',
E'Workspace "{{.Labels.name}}" marked as dormant',
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.',
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The wording is a little weird here. Can we change it and add a link to the dormancy docs:

I don't think we need to include the initiator, just improve the reasons (see end of message).

Hi {user-name}, your workspace {ws-name} has been marked dormant because of {reason}.

Append if dormancy cleanup is enabled and dormancy_hours < 24:

Dormant workspaces are automatically deleted after {dormancy_hours} hours of inactivity.

Append if dormancy cleanup is enabled, and dormancy_hours > 24:

Dormant workspaces are automatically deleted after {dormancy_hours // 24} days of inactivity.

To prevent deletion, use your workspace with the link below.


The reasons also fit into differing grammatic context:

  • Lifecycle executor: "breached the template's threshold for inactivity"
  • API: "requested by user"
  • Template: "template updated to new dormancy policy"

If we use these, they'd fit the above messages better.

  • Lifcycle: "prolonged inactivity, exceeding the dormancy threshold"
  • API: "a user request"
  • Template: "an update to the template's dormancy"

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Going to refactor the templates in the next PR to avoid further postponement.

'Workspace Events',
'[
{
"label": "View workspace",
"url": "{{ base_url }}/@{{.UserName}}/{{.Labels.name}}"
}
]'::jsonb
),
(
'51ce2fdf-c9ca-4be1-8d70-628674f9bc42',
'Workspace Marked for Deletion',
E'Workspace "{{.Labels.name}}" marked for deletion',
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.',
'Workspace Events',
'[
{
"label": "View workspace",
"url": "{{ base_url }}/@{{.UserName}}/{{.Labels.name}}"
}
]'::jsonb
);
2 changes: 1 addition & 1 deletion coderd/database/querier.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

43 changes: 39 additions & 4 deletions coderd/database/queries.sql.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading