Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
24 changes: 17 additions & 7 deletions coderd/autobuild/lifecycle_executor.go
Original file line number Diff line number Diff line change
Expand Up @@ -382,8 +382,12 @@ func (e *Executor) runOnce(t time.Time) Stats {
Old: wsOld.WorkspaceTable(),
New: wsNew,
}
// To keep the `ws` accurate without doing a sql fetch
// To keep the `ws` accurate without doing a sql fetch.
// deleting_at is computed atomically inside the UPDATE from
// the workspace's template_id, so it reflects the auto-delete
// deadline the database persisted.
ws.DormantAt = wsNew.DormantAt
ws.DeletingAt = wsNew.DeletingAt

shouldNotifyDormancy = true

Expand Down Expand Up @@ -484,16 +488,22 @@ func (e *Executor) runOnce(t time.Time) Stats {
}
}
if shouldNotifyDormancy {
dormantTime := dbtime.Now().Add(time.Duration(tmpl.TimeTilDormant))
labels := map[string]string{
"name": ws.Name,
"reason": "inactivity exceeded the dormancy threshold",
}
// DeletingAt is set by the UPDATE only when the template's
// time_til_dormant_autodelete is non-zero, so skip the label when
// auto-delete is disabled so the body omits the deletion
// timeline.
if ws.DeletingAt.Valid {
labels["timeTilDelete"] = humanize.Time(ws.DeletingAt.Time)
}
_, err = e.notificationsEnqueuer.Enqueue(
e.ctx,
ws.OwnerID,
notifications.TemplateWorkspaceDormant,
map[string]string{
"name": ws.Name,
"reason": "inactivity exceeded the dormancy threshold",
"timeTilDormant": humanize.Time(dormantTime),
},
labels,
"lifecycle_executor",
ws.ID,
ws.OwnerID,
Expand Down
88 changes: 88 additions & 0 deletions coderd/autobuild/lifecycle_executor_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1437,6 +1437,94 @@ func TestNotifications(t *testing.T) {
require.Contains(t, sent[0].Targets, workspace.ID)
require.Contains(t, sent[0].Targets, workspace.OrganizationID)
require.Contains(t, sent[0].Targets, workspace.OwnerID)

// The template does not configure auto-delete, so the body must not
// indicate a deletion timeline.
require.NotContains(t, sent[0].Labels, "timeTilDelete")
require.Equal(t, workspace.Name, sent[0].Labels["name"])
require.Equal(t, "inactivity exceeded the dormancy threshold", sent[0].Labels["reason"])
})

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

// Setup template with dormancy and auto-delete and create a workspace
// with it. The two durations are intentionally far apart to reliably
// check what's rendered in the notification.
var (
ticker = make(chan time.Time)
statCh = make(chan autobuild.Stats)
notifyEnq = notificationstest.FakeEnqueuer{}
// 35 days is inside humanize.Time's "1 month" bucket (between 30 and 60 days).
timeTilDormant = time.Minute
timeTilDormantAutoDelete = 35 * 24 * time.Hour
client, db = coderdtest.NewWithDatabase(t, &coderdtest.Options{
AutobuildTicker: ticker,
AutobuildStats: statCh,
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)
template.TimeTilDormantAutoDelete = int64(options.TimeTilDormantAutoDelete)
return schedule.NewAGPLTemplateScheduleStore().Set(ctx, db, template, options)
},
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,
TimeTilDormantAutoDelete: timeTilDormantAutoDelete,
}, 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, func(ctr *codersdk.CreateTemplateRequest) {
ctr.TimeTilDormantMillis = ptr.Ref(timeTilDormant.Milliseconds())
ctr.TimeTilDormantAutoDeleteMillis = ptr.Ref(timeTilDormantAutoDelete.Milliseconds())
})
userClient, _ := coderdtest.CreateAnotherUser(t, client, admin.OrganizationID)
workspace := coderdtest.CreateWorkspace(t, userClient, template.ID)
coderdtest.AwaitWorkspaceBuildJobCompleted(t, userClient, workspace.LatestBuild.ID)

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

p, err := coderdtest.GetProvisionerForTags(db, time.Now(), workspace.OrganizationID, nil)
require.NoError(t, err)

// Wait for workspace to become dormant
notifyEnq.Clear()
tickTime := workspace.LastUsedAt.Add(timeTilDormant * 3)
coderdtest.UpdateProvisionerLastSeenAt(t, db, p.ID, tickTime)
ticker <- tickTime
_ = testutil.TryReceive(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)

// The notification body should render the deletion countdown using the template's
// `time_til_dormant_autodelete` value. With auto-delete at 35 days and dormancy
// at 1 minute, humanize.Time renders the label as "1 month from now".
sent := notifyEnq.Sent()
require.Len(t, sent, 1)
require.Equal(t, sent[0].TemplateID, notifications.TemplateWorkspaceDormant)
require.Contains(t, sent[0].Labels, "timeTilDelete")
require.Contains(t, sent[0].Labels["timeTilDelete"], "1 month",
"timeTilDelete must humanize TimeTilDormantAutoDelete, got %q",
sent[0].Labels["timeTilDelete"])
require.NotContains(t, sent[0].Labels["timeTilDelete"], "ago",
"timeTilDelete must be a future timestamp, got %q",
sent[0].Labels["timeTilDelete"])
})
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
-- Revert to the body left by migration 000518
-- (000311's wording with the corrected docs URL).
UPDATE notification_templates SET body_template = E'Your workspace **{{.Labels.name}}** has been marked as [**dormant**](https://coder.com/docs/admin/templates/managing-templates/schedule#dormancy-threshold) due to inactivity exceeding the dormancy threshold.\n\n' ||
E'This workspace will be automatically deleted in {{.Labels.timeTilDormant}} if it remains inactive.\n\n' ||
E'To prevent deletion, activate your workspace using the link below.' WHERE id = '0ea69165-ec14-4314-91f1-69566ac3c5a0';
Comment thread
zedkipp marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
-- Update the dormant workspace notification body so that the deletion
-- countdown references a dedicated `timeTilDelete` label, and to only
-- include the deletion time if the templates' `time_til_dormant_autodelete`
-- is enabled.
UPDATE notification_templates SET body_template = E'Your workspace **{{.Labels.name}}** has been marked as [**dormant**](https://coder.com/docs/admin/templates/managing-templates/schedule#dormancy-threshold) due to inactivity exceeding the dormancy threshold.\n\n' ||
E'{{ if .Labels.timeTilDelete -}}\n' ||
E'This workspace will be automatically deleted in {{.Labels.timeTilDelete}} if it remains inactive.\n\n' ||
E'To prevent deletion, activate your workspace using the link below.\n' ||
E'{{- else -}}\n' ||
E'Activate your workspace using the link below to resume working in it.\n' ||
E'{{- end }}' WHERE id = '0ea69165-ec14-4314-91f1-69566ac3c5a0';
28 changes: 23 additions & 5 deletions coderd/notifications/notifications_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -788,11 +788,29 @@ func TestNotificationTemplates_Golden(t *testing.T) {
UserEmail: "bobby@coder.com",
UserUsername: "bobby",
Labels: map[string]string{
"name": "bobby-workspace",
"reason": "breached the template's threshold for inactivity",
"initiator": "autobuild",
"dormancyHours": "24",
"timeTilDormant": "24 hours",
"name": "bobby-workspace",
"reason": "breached the template's threshold for inactivity",
"initiator": "autobuild",
"dormancyHours": "24",
"timeTilDelete": "24 hours",
},
},
},
{
// TemplateWorkspaceDormant body should not promise auto-deletion
// when the template has no `time_til_dormant_autodelete` set, in
// which case the enqueue sites leave `timeTilDelete` unset.
name: "TemplateWorkspaceDormant_NoAutoDelete",
id: notifications.TemplateWorkspaceDormant,
payload: types.MessagePayload{
UserName: "Bobby",
UserEmail: "bobby@coder.com",
UserUsername: "bobby",
Labels: map[string]string{
"name": "bobby-workspace",
"reason": "breached the template's threshold for inactivity",
"initiator": "autobuild",
"dormancyHours": "24",
},
},
},
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
From: system@coder.com
To: bobby@coder.com
Subject: Workspace "bobby-workspace" marked as dormant
Message-Id: 02ee4935-73be-4fa1-a290-ff9999026b13@blush-whale-48
Date: Fri, 11 Oct 2024 09:03:06 +0000
Content-Type: multipart/alternative; boundary=bbe61b741255b6098bb6b3c1f41b885773df633cb18d2a3002b68e4bc9c4
MIME-Version: 1.0

--bbe61b741255b6098bb6b3c1f41b885773df633cb18d2a3002b68e4bc9c4
Content-Transfer-Encoding: quoted-printable
Content-Type: text/plain; charset=UTF-8

Hi Bobby,

Your workspace bobby-workspace has been marked as dormant (https://coder.co=
m/docs/admin/templates/managing-templates/schedule#dormancy-threshold) due =
to inactivity exceeding the dormancy threshold.

Activate your workspace using the link below to resume working in it.


View workspace: http://test.com/@bobby/bobby-workspace

--bbe61b741255b6098bb6b3c1f41b885773df633cb18d2a3002b68e4bc9c4
Content-Transfer-Encoding: quoted-printable
Content-Type: text/html; charset=UTF-8

<!doctype html>
<html lang=3D"en">
<head>
<meta charset=3D"UTF-8" />
<meta name=3D"viewport" content=3D"width=3Ddevice-width, initial-scale=
=3D1.0" />
<title>Workspace "bobby-workspace" marked as dormant</title>
</head>
<body style=3D"margin: 0; padding: 0; font-family: -apple-system, system-=
ui, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarel=
l', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif; color: #020617=
; background: #f8fafc;">
<div style=3D"max-width: 600px; margin: 20px auto; padding: 60px; borde=
r: 1px solid #e2e8f0; border-radius: 8px; background-color: #fff; text-alig=
n: left; font-size: 14px; line-height: 1.5;">
<div style=3D"text-align: center;">
<img src=3D"https://coder.com/coder-logo-horizontal.png" alt=3D"Cod=
er Logo" style=3D"height: 40px;" />
</div>
<h1 style=3D"text-align: center; font-size: 24px; font-weight: 400; m=
argin: 8px 0 32px; line-height: 1.5;">
Workspace "bobby-workspace" marked as dormant
</h1>
<div style=3D"line-height: 1.5;">
<p>Hi Bobby,</p>
<p>Your workspace <strong>bobby-workspace</strong> has been marked =
as <a href=3D"https://coder.com/docs/admin/templates/managing-templates/sch=
edule#dormancy-threshold"><strong>dormant</strong></a> due to inactivity ex=
ceeding the dormancy threshold.</p>

<p>Activate your workspace using the link below to resume working in it.</p=
>
</div>
<div style=3D"text-align: center; margin-top: 32px;">
=20
<a href=3D"http://test.com/@bobby/bobby-workspace" style=3D"display=
: inline-block; padding: 13px 24px; background-color: #020617; color: #f8fa=
fc; text-decoration: none; border-radius: 8px; margin: 0 4px;">
View workspace
</a>
=20
</div>
<div style=3D"border-top: 1px solid #e2e8f0; color: #475569; font-siz=
e: 12px; margin-top: 64px; padding-top: 24px; line-height: 1.6;">
<p>&copy;&nbsp;2024&nbsp;Coder. All rights reserved&nbsp;-&nbsp;<a =
href=3D"http://test.com" style=3D"color: #2563eb; text-decoration: none;">h=
ttp://test.com</a></p>
<p><a href=3D"http://test.com/settings/notifications" style=3D"colo=
r: #2563eb; text-decoration: none;">Click here to manage your notification =
settings</a></p>
<p><a href=3D"http://test.com/settings/notifications?disabled=3D0ea=
69165-ec14-4314-91f1-69566ac3c5a0" style=3D"color: #2563eb; text-decoration=
: none;">Stop receiving emails like this</a></p>
</div>
</div>
</body>
</html>

--bbe61b741255b6098bb6b3c1f41b885773df633cb18d2a3002b68e4bc9c4--
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
"initiator": "autobuild",
"name": "bobby-workspace",
"reason": "breached the template's threshold for inactivity",
"timeTilDormant": "24 hours"
"timeTilDelete": "24 hours"
},
"data": null,
"targets": null
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
{
"_version": "1.1",
"msg_id": "00000000-0000-0000-0000-000000000000",
"payload": {
"_version": "1.2",
"notification_name": "Workspace Marked as Dormant",
"notification_template_id": "00000000-0000-0000-0000-000000000000",
"user_id": "00000000-0000-0000-0000-000000000000",
"user_email": "bobby@coder.com",
"user_name": "Bobby",
"user_username": "bobby",
"actions": [
{
"label": "View workspace",
"url": "http://test.com/@bobby/bobby-workspace"
}
],
"labels": {
"dormancyHours": "24",
"initiator": "autobuild",
"name": "bobby-workspace",
"reason": "breached the template's threshold for inactivity"
},
"data": null,
"targets": null
},
"title": "Workspace \"bobby-workspace\" marked as dormant",
"title_markdown": "Workspace \"bobby-workspace\" marked as dormant",
"body": "Your workspace bobby-workspace has been marked as dormant (https://coder.com/docs/admin/templates/managing-templates/schedule#dormancy-threshold) due to inactivity exceeding the dormancy threshold.\n\nActivate your workspace using the link below to resume working in it.",
"body_markdown": "Your workspace **bobby-workspace** has been marked as [**dormant**](https://coder.com/docs/admin/templates/managing-templates/schedule#dormancy-threshold) due to inactivity exceeding the dormancy threshold.\n\nActivate your workspace using the link below to resume working in it."
}
31 changes: 13 additions & 18 deletions coderd/workspaces.go

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Worth testing the new branch in workspaces_test.go?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good call. I added similar test coverage here.

Original file line number Diff line number Diff line change
Expand Up @@ -1448,29 +1448,24 @@ func (api *API) putWorkspaceDormant(rw http.ResponseWriter, r *http.Request) {
)
}

tmpl, tmplErr := api.Database.GetTemplateByID(ctx, newWorkspace.TemplateID)
if tmplErr != nil {
api.Logger.Warn(
ctx,
"failed to fetch the template of the workspace marked as dormant",
slog.Error(err),
slog.F("workspace_id", newWorkspace.ID),
slog.F("template_id", newWorkspace.TemplateID),
)
}

if initiatorErr == nil && tmplErr == nil {
dormantTime := dbtime.Time(now).Add(time.Duration(tmpl.TimeTilDormant))
if initiatorErr == nil {
labels := map[string]string{
"name": newWorkspace.Name,
"reason": "a " + initiator.Username + " request",
}
// DeletingAt is set by the UPDATE only when the template's
// time_til_dormant_autodelete is non-zero, so skip the label when
// auto-delete is disabled so the body omits the deletion
// timeline.
if newWorkspace.DeletingAt.Valid {
labels["timeTilDelete"] = humanize.Time(newWorkspace.DeletingAt.Time)
}
_, err = api.NotificationsEnqueuer.Enqueue(
// nolint:gocritic // Need notifier actor to enqueue notifications
dbauthz.AsNotifier(ctx),
newWorkspace.OwnerID,
notifications.TemplateWorkspaceDormant,
map[string]string{
"name": newWorkspace.Name,
"reason": "a " + initiator.Username + " request",
"timeTilDormant": humanize.Time(dormantTime),
},
labels,
"api",
newWorkspace.ID,
newWorkspace.OwnerID,
Expand Down
Loading
Loading