fix: fix dormancy notifications (#14029)

This commit is contained in:
Bruno Quaresma
2024-07-29 11:20:04 -03:00
committed by GitHub
parent 22143d3e80
commit 58b810fb0a
12 changed files with 194 additions and 128 deletions

View File

@ -19,7 +19,6 @@ 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"
@ -145,10 +144,11 @@ func (e *Executor) runOnce(t time.Time) Stats {
var (
job *database.ProvisionerJob
auditLog *auditParams
dormantNotification *dormancy.WorkspaceDormantNotification
shouldNotifyDormancy bool
nextBuild *database.WorkspaceBuild
activeTemplateVersion database.TemplateVersion
ws database.Workspace
tmpl database.Template
didAutoUpdate bool
)
err := e.db.InTx(func(tx database.Store) error {
@ -182,17 +182,17 @@ func (e *Executor) runOnce(t time.Time) Stats {
return xerrors.Errorf("get template scheduling options: %w", err)
}
template, err := tx.GetTemplateByID(e.ctx, ws.TemplateID)
tmpl, err = tx.GetTemplateByID(e.ctx, ws.TemplateID)
if err != nil {
return xerrors.Errorf("get template by ID: %w", err)
}
activeTemplateVersion, err = tx.GetTemplateVersionByID(e.ctx, template.ActiveVersionID)
activeTemplateVersion, err = tx.GetTemplateVersionByID(e.ctx, tmpl.ActiveVersionID)
if err != nil {
return xerrors.Errorf("get active template version by ID: %w", err)
}
accessControl := (*(e.accessControlStore.Load())).GetTemplateAccessControl(template)
accessControl := (*(e.accessControlStore.Load())).GetTemplateAccessControl(tmpl)
nextTransition, reason, err := getNextTransition(user, ws, latestBuild, latestJob, templateSchedule, currentTick)
if err != nil {
@ -215,7 +215,7 @@ func (e *Executor) runOnce(t time.Time) Stats {
log.Debug(e.ctx, "autostarting with active version")
builder = builder.ActiveVersion()
if latestBuild.TemplateVersionID != template.ActiveVersionID {
if latestBuild.TemplateVersionID != tmpl.ActiveVersionID {
// control flag to know if the workspace was auto-updated,
// so the lifecycle executor can notify the user
didAutoUpdate = true
@ -248,12 +248,7 @@ 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",
}
shouldNotifyDormancy = true
log.Info(e.ctx, "dormant workspace",
slog.F("last_used_at", ws.LastUsedAt),
@ -325,14 +320,24 @@ func (e *Executor) runOnce(t time.Time) Stats {
return xerrors.Errorf("post provisioner job to pubsub: %w", err)
}
}
if dormantNotification != nil {
_, err = dormancy.NotifyWorkspaceDormant(
if shouldNotifyDormancy {
_, err = e.notificationsEnqueuer.Enqueue(
e.ctx,
e.notificationsEnqueuer,
*dormantNotification,
ws.OwnerID,
notifications.TemplateWorkspaceDormant,
map[string]string{
"name": ws.Name,
"reason": "inactivity exceeded the dormancy threshold",
"timeTilDormant": time.Duration(tmpl.TimeTilDormant).String(),
},
"lifecycle_executor",
ws.ID,
ws.OwnerID,
ws.TemplateID,
ws.OrganizationID,
)
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))
log.Warn(e.ctx, "failed to notify of workspace marked as dormant", slog.Error(err), slog.F("workspace_id", ws.ID))
}
}
return nil

View File

@ -1122,7 +1122,6 @@ func TestNotifications(t *testing.T) {
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")
})
}

View File

@ -0,0 +1,16 @@
UPDATE notification_templates
SET
body_template = E'Hi {{.UserName}}\n\n' ||
E'Your workspace **{{.Labels.name}}** has been marked as [**dormant**](https://coder.com/docs/templates/schedule#dormancy-threshold-enterprise) because of {{.Labels.reason}}.\n' ||
E'Dormant workspaces are [automatically deleted](https://coder.com/docs/templates/schedule#dormancy-auto-deletion-enterprise) after {{.Labels.timeTilDormant}} of inactivity.\n' ||
E'To prevent deletion, use your workspace with the link below.'
WHERE
id = '0ea69165-ec14-4314-91f1-69566ac3c5a0';
UPDATE notification_templates
SET
body_template = E'Hi {{.UserName}}\n\n' ||
E'Your workspace **{{.Labels.name}}** has been marked for **deletion** after {{.Labels.timeTilDormant}} of [dormancy](https://coder.com/docs/templates/schedule#dormancy-auto-deletion-enterprise) because of {{.Labels.reason}}.\n' ||
E'To prevent deletion, use your workspace with the link below.'
WHERE
id = '51ce2fdf-c9ca-4be1-8d70-628674f9bc42';

View File

@ -132,4 +132,3 @@ WHERE id IN
-- name: GetNotificationMessagesByStatus :many
SELECT * FROM notification_messages WHERE status = @status LIMIT sqlc.arg('limit')::int;

View File

@ -1,75 +0,0 @@
// This package is located outside of the enterprise package to ensure
// accessibility in the putWorkspaceDormant function. This design choice allows
// workspaces to be taken out of dormancy even if the license has expired,
// ensuring critical functionality remains available without an active
// enterprise license.
package dormancy
import (
"context"
"github.com/google/uuid"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/notifications"
)
type WorkspaceDormantNotification struct {
Workspace database.Workspace
Initiator string
Reason string
CreatedBy string
}
func NotifyWorkspaceDormant(
ctx context.Context,
enqueuer notifications.Enqueuer,
notification WorkspaceDormantNotification,
) (id *uuid.UUID, err error) {
labels := map[string]string{
"name": notification.Workspace.Name,
"initiator": notification.Initiator,
"reason": notification.Reason,
}
return enqueuer.Enqueue(
ctx,
notification.Workspace.OwnerID,
notifications.TemplateWorkspaceDormant,
labels,
notification.CreatedBy,
// Associate this notification with all the related entities.
notification.Workspace.ID,
notification.Workspace.OwnerID,
notification.Workspace.TemplateID,
notification.Workspace.OrganizationID,
)
}
type WorkspaceMarkedForDeletionNotification struct {
Workspace database.Workspace
Reason string
CreatedBy string
}
func NotifyWorkspaceMarkedForDeletion(
ctx context.Context,
enqueuer notifications.Enqueuer,
notification WorkspaceMarkedForDeletionNotification,
) (id *uuid.UUID, err error) {
labels := map[string]string{
"name": notification.Workspace.Name,
"reason": notification.Reason,
}
return enqueuer.Enqueue(
ctx,
notification.Workspace.OwnerID,
notifications.TemplateWorkspaceMarkedForDeletion,
labels,
notification.CreatedBy,
// Associate this notification with all the related entities.
notification.Workspace.ID,
notification.Workspace.OwnerID,
notification.Workspace.TemplateID,
notification.Workspace.OrganizationID,
)
}

View File

@ -29,6 +29,7 @@ import (
"github.com/coder/coder/v2/coderd/database/dbtestutil"
"github.com/coder/coder/v2/coderd/notifications"
"github.com/coder/coder/v2/coderd/notifications/dispatch"
"github.com/coder/coder/v2/coderd/notifications/render"
"github.com/coder/coder/v2/coderd/notifications/types"
"github.com/coder/coder/v2/coderd/util/syncmap"
"github.com/coder/coder/v2/codersdk"
@ -603,6 +604,107 @@ func TestNotifierPaused(t *testing.T) {
}, testutil.WaitShort, testutil.IntervalFast)
}
func TestNotifcationTemplatesBody(t *testing.T) {
t.Parallel()
if !dbtestutil.WillUsePostgres() {
t.Skip("This test requires postgres; it relies on the notification templates added by migrations in the database")
}
tests := []struct {
name string
id uuid.UUID
payload types.MessagePayload
}{
{
name: "TemplateWorkspaceDeleted",
id: notifications.TemplateWorkspaceDeleted,
payload: types.MessagePayload{
UserName: "bobby",
Labels: map[string]string{
"name": "bobby-workspace",
"reason": "autodeleted due to dormancy",
"initiator": "autobuild",
},
},
},
{
name: "TemplateWorkspaceAutobuildFailed",
id: notifications.TemplateWorkspaceAutobuildFailed,
payload: types.MessagePayload{
UserName: "bobby",
Labels: map[string]string{
"name": "bobby-workspace",
"reason": "autostart",
},
},
},
{
name: "TemplateWorkspaceDormant",
id: notifications.TemplateWorkspaceDormant,
payload: types.MessagePayload{
UserName: "bobby",
Labels: map[string]string{
"name": "bobby-workspace",
"reason": "breached the template's threshold for inactivity",
"initiator": "autobuild",
"dormancyHours": "24",
},
},
},
{
name: "TemplateWorkspaceAutoUpdated",
id: notifications.TemplateWorkspaceAutoUpdated,
payload: types.MessagePayload{
UserName: "bobby",
Labels: map[string]string{
"name": "bobby-workspace",
"template_version_name": "1.0",
},
},
},
{
name: "TemplateWorkspaceMarkedForDeletion",
id: notifications.TemplateWorkspaceMarkedForDeletion,
payload: types.MessagePayload{
UserName: "bobby",
Labels: map[string]string{
"name": "bobby-workspace",
"reason": "template updated to new dormancy policy",
"dormancyHours": "24",
},
},
},
}
for _, tc := range tests {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
_, _, sql := dbtestutil.NewDBWithSQLDB(t)
var (
titleTmpl string
bodyTmpl string
)
err := sql.
QueryRow("SELECT title_template, body_template FROM notification_templates WHERE id = $1 LIMIT 1", tc.id).
Scan(&titleTmpl, &bodyTmpl)
require.NoError(t, err, "failed to query body template for template:", tc.id)
title, err := render.GoTemplate(titleTmpl, tc.payload, nil)
require.NoError(t, err, "failed to render notification title template")
require.NotEmpty(t, title, "title should not be empty")
body, err := render.GoTemplate(bodyTmpl, tc.payload, nil)
require.NoError(t, err, "failed to render notification body template")
require.NotEmpty(t, body, "body should not be empty")
})
}
}
type fakeHandler struct {
mu sync.RWMutex
succeeded, failed []string

View File

@ -1101,13 +1101,11 @@ func (s *server) notifyWorkspaceBuildFailed(ctx context.Context, workspace datab
return // failed workspace build initiated by a user should not notify
}
reason = string(build.Reason)
initiator := "autobuild"
if _, err := s.NotificationsEnqueuer.Enqueue(ctx, workspace.OwnerID, notifications.TemplateWorkspaceAutobuildFailed,
map[string]string{
"name": workspace.Name,
"initiator": initiator,
"reason": reason,
"name": workspace.Name,
"reason": reason,
}, "provisionerdserver",
// Associate this notification with all the related entities.
workspace.ID, workspace.OwnerID, workspace.TemplateID, workspace.OrganizationID,

View File

@ -1797,7 +1797,6 @@ func TestNotifications(t *testing.T) {
require.Contains(t, notifEnq.Sent[0].Targets, workspace.ID)
require.Contains(t, notifEnq.Sent[0].Targets, workspace.OrganizationID)
require.Contains(t, notifEnq.Sent[0].Targets, user.ID)
require.Equal(t, "autobuild", notifEnq.Sent[0].Labels["initiator"])
require.Equal(t, string(tc.buildReason), notifEnq.Sent[0].Labels["reason"])
} else {
require.Len(t, notifEnq.Sent, 0)

View File

@ -23,9 +23,9 @@ import (
"github.com/coder/coder/v2/coderd/database/dbauthz"
"github.com/coder/coder/v2/coderd/database/dbtime"
"github.com/coder/coder/v2/coderd/database/provisionerjobs"
"github.com/coder/coder/v2/coderd/dormancy"
"github.com/coder/coder/v2/coderd/httpapi"
"github.com/coder/coder/v2/coderd/httpmw"
"github.com/coder/coder/v2/coderd/notifications"
"github.com/coder/coder/v2/coderd/rbac"
"github.com/coder/coder/v2/coderd/rbac/policy"
"github.com/coder/coder/v2/coderd/schedule/cron"
@ -953,25 +953,43 @@ func (api *API) putWorkspaceDormant(rw http.ResponseWriter, r *http.Request) {
// We don't need to notify the owner if they are the one making the request.
if req.Dormant && apiKey.UserID != workspace.OwnerID {
initiator, err := api.Database.GetUserByID(ctx, apiKey.UserID)
if err != nil {
initiator, initiatorErr := api.Database.GetUserByID(ctx, apiKey.UserID)
if initiatorErr != nil {
api.Logger.Warn(
ctx,
"failed to fetch the user that marked the workspace",
"failed to fetch the user that marked the workspace as dormant",
slog.Error(err),
slog.F("workspace_id", workspace.ID),
slog.F("user_id", apiKey.UserID),
)
} else {
_, err = dormancy.NotifyWorkspaceDormant(
}
tmpl, tmplErr := api.Database.GetTemplateByID(ctx, workspace.TemplateID)
if tmplErr != nil {
api.Logger.Warn(
ctx,
api.NotificationsEnqueuer,
dormancy.WorkspaceDormantNotification{
Workspace: workspace,
Initiator: initiator.Username,
Reason: "requested by user",
CreatedBy: "api",
"failed to fetch the template of the workspace marked as dormant",
slog.Error(err),
slog.F("workspace_id", workspace.ID),
slog.F("template_id", workspace.TemplateID),
)
}
if initiatorErr == nil && tmplErr == nil {
_, err = api.NotificationsEnqueuer.Enqueue(
ctx,
workspace.OwnerID,
notifications.TemplateWorkspaceDormant,
map[string]string{
"name": workspace.Name,
"reason": "a " + initiator.Username + " request",
"timeTilDormant": time.Duration(tmpl.TimeTilDormant).String(),
},
"api",
workspace.ID,
workspace.OwnerID,
workspace.TemplateID,
workspace.OrganizationID,
)
if err != nil {
api.Logger.Warn(ctx, "failed to notify of workspace marked as dormant", slog.Error(err))

View File

@ -3457,13 +3457,13 @@ func TestNotifications(t *testing.T) {
IncludeProvisionerDaemon: true,
NotificationsEnqueuer: notifyEnq,
})
user = coderdtest.CreateFirstUser(t, client)
memberClient, member = coderdtest.CreateAnotherUser(t, client, user.OrganizationID, rbac.RoleOwner())
version = coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
_ = coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
template = coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
workspace = coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID)
_ = coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID)
user = coderdtest.CreateFirstUser(t, client)
memberClient, _ = coderdtest.CreateAnotherUser(t, client, user.OrganizationID, rbac.RoleOwner())
version = coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
_ = coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
template = coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
workspace = coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID)
_ = coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID)
)
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
@ -3483,7 +3483,6 @@ func TestNotifications(t *testing.T) {
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"], member.Username)
})
t.Run("InitiatorIsOwner", func(t *testing.T) {

View File

@ -16,7 +16,6 @@ import (
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbauthz"
"github.com/coder/coder/v2/coderd/database/dbtime"
"github.com/coder/coder/v2/coderd/dormancy"
"github.com/coder/coder/v2/coderd/notifications"
agpl "github.com/coder/coder/v2/coderd/schedule"
"github.com/coder/coder/v2/coderd/tracing"
@ -205,18 +204,25 @@ func (s *EnterpriseTemplateScheduleStore) Set(ctx context.Context, db database.S
return database.Template{}, err
}
for _, workspace := range markedForDeletion {
_, err = dormancy.NotifyWorkspaceMarkedForDeletion(
for _, ws := range markedForDeletion {
_, err = s.enqueuer.Enqueue(
ctx,
s.enqueuer,
dormancy.WorkspaceMarkedForDeletionNotification{
Workspace: workspace,
Reason: "template updated to new dormancy policy",
CreatedBy: "scheduletemplate",
ws.OwnerID,
notifications.TemplateWorkspaceMarkedForDeletion,
map[string]string{
"name": ws.Name,
"reason": "an update to the template's dormancy",
"timeTilDormant": opts.TimeTilDormantAutoDelete.String(),
},
"scheduletemplate",
// Associate this notification with all the related entities.
ws.ID,
ws.OwnerID,
ws.TemplateID,
ws.OrganizationID,
)
if err != nil {
s.logger.Warn(ctx, "failed to notify of workspace marked for deletion", slog.Error(err), slog.F("workspace_id", workspace.ID))
s.logger.Warn(ctx, "failed to notify of workspace marked for deletion", slog.Error(err), slog.F("workspace_id", ws.ID))
}
}