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

This commit is contained in:
Bruno Quaresma
2024-07-24 13:38:21 -03:00
committed by GitHub
parent ccb5b4df80
commit 0d9615b4fd
25 changed files with 650 additions and 118 deletions
+28 -9
View File
@@ -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"
@@ -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
}
@@ -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
@@ -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),
@@ -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",
@@ -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 {
+65 -1
View File
@@ -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"
@@ -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,
@@ -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)
+4 -1
View File
@@ -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]{}
@@ -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 {
+8 -5
View File
@@ -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 {
+5 -3
View File
@@ -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
@@ -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 {
+3 -3
View File
@@ -2279,11 +2279,11 @@ func (m metricsStore) UpdateWorkspaceTTL(ctx context.Context, arg database.Updat
return r0
}
func (m metricsStore) UpdateWorkspacesDormantDeletingAtByTemplateID(ctx context.Context, arg database.UpdateWorkspacesDormantDeletingAtByTemplateIDParams) error {
func (m metricsStore) UpdateWorkspacesDormantDeletingAtByTemplateID(ctx context.Context, arg database.UpdateWorkspacesDormantDeletingAtByTemplateIDParams) ([]database.Workspace, error) {
start := time.Now()
r0 := m.s.UpdateWorkspacesDormantDeletingAtByTemplateID(ctx, arg)
r0, r1 := m.s.UpdateWorkspacesDormantDeletingAtByTemplateID(ctx, arg)
m.queryLatencies.WithLabelValues("UpdateWorkspacesDormantDeletingAtByTemplateID").Observe(time.Since(start).Seconds())
return r0
return r0, r1
}
func (m metricsStore) UpsertAnnouncementBanners(ctx context.Context, value string) error {
+4 -3
View File
@@ -4776,11 +4776,12 @@ func (mr *MockStoreMockRecorder) UpdateWorkspaceTTL(arg0, arg1 any) *gomock.Call
}
// UpdateWorkspacesDormantDeletingAtByTemplateID mocks base method.
func (m *MockStore) UpdateWorkspacesDormantDeletingAtByTemplateID(arg0 context.Context, arg1 database.UpdateWorkspacesDormantDeletingAtByTemplateIDParams) error {
func (m *MockStore) UpdateWorkspacesDormantDeletingAtByTemplateID(arg0 context.Context, arg1 database.UpdateWorkspacesDormantDeletingAtByTemplateIDParams) ([]database.Workspace, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "UpdateWorkspacesDormantDeletingAtByTemplateID", arg0, arg1)
ret0, _ := ret[0].(error)
return ret0
ret0, _ := ret[0].([]database.Workspace)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// UpdateWorkspacesDormantDeletingAtByTemplateID indicates an expected call of UpdateWorkspacesDormantDeletingAtByTemplateID.
@@ -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';
@@ -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.',
'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
);
+1 -1
View File
@@ -448,7 +448,7 @@ type sqlcQuerier interface {
UpdateWorkspaceProxy(ctx context.Context, arg UpdateWorkspaceProxyParams) (WorkspaceProxy, error)
UpdateWorkspaceProxyDeleted(ctx context.Context, arg UpdateWorkspaceProxyDeletedParams) error
UpdateWorkspaceTTL(ctx context.Context, arg UpdateWorkspaceTTLParams) error
UpdateWorkspacesDormantDeletingAtByTemplateID(ctx context.Context, arg UpdateWorkspacesDormantDeletingAtByTemplateIDParams) error
UpdateWorkspacesDormantDeletingAtByTemplateID(ctx context.Context, arg UpdateWorkspacesDormantDeletingAtByTemplateIDParams) ([]Workspace, error)
UpsertAnnouncementBanners(ctx context.Context, value string) error
UpsertAppSecurityKey(ctx context.Context, value string) error
UpsertApplicationName(ctx context.Context, value string) error
+39 -4
View File
@@ -14082,7 +14082,7 @@ func (q *sqlQuerier) UpdateWorkspaceTTL(ctx context.Context, arg UpdateWorkspace
return err
}
const updateWorkspacesDormantDeletingAtByTemplateID = `-- name: UpdateWorkspacesDormantDeletingAtByTemplateID :exec
const updateWorkspacesDormantDeletingAtByTemplateID = `-- name: UpdateWorkspacesDormantDeletingAtByTemplateID :many
UPDATE workspaces
SET
deleting_at = CASE
@@ -14095,6 +14095,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
`
type UpdateWorkspacesDormantDeletingAtByTemplateIDParams struct {
@@ -14103,9 +14104,43 @@ type UpdateWorkspacesDormantDeletingAtByTemplateIDParams struct {
TemplateID uuid.UUID `db:"template_id" json:"template_id"`
}
func (q *sqlQuerier) UpdateWorkspacesDormantDeletingAtByTemplateID(ctx context.Context, arg UpdateWorkspacesDormantDeletingAtByTemplateIDParams) error {
_, err := q.db.ExecContext(ctx, updateWorkspacesDormantDeletingAtByTemplateID, arg.TimeTilDormantAutodeleteMs, arg.DormantAt, arg.TemplateID)
return err
func (q *sqlQuerier) UpdateWorkspacesDormantDeletingAtByTemplateID(ctx context.Context, arg UpdateWorkspacesDormantDeletingAtByTemplateIDParams) ([]Workspace, error) {
rows, err := q.db.QueryContext(ctx, updateWorkspacesDormantDeletingAtByTemplateID, arg.TimeTilDormantAutodeleteMs, arg.DormantAt, arg.TemplateID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []Workspace
for rows.Next() {
var i Workspace
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,
); 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 getWorkspaceAgentScriptsByAgentIDs = `-- name: GetWorkspaceAgentScriptsByAgentIDs :many
+3 -2
View File
@@ -646,7 +646,7 @@ WHERE
RETURNING
workspaces.*;
-- name: UpdateWorkspacesDormantDeletingAtByTemplateID :exec
-- name: UpdateWorkspacesDormantDeletingAtByTemplateID :many
UPDATE workspaces
SET
deleting_at = CASE
@@ -658,7 +658,8 @@ SET
WHERE
template_id = @template_id
AND
dormant_at IS NOT NULL;
dormant_at IS NOT NULL
RETURNING *;
-- name: UpdateTemplateWorkspacesLastUsedAt :exec
UPDATE workspaces
+75
View File
@@ -0,0 +1,75 @@
// 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,
)
}
+5 -3
View File
@@ -7,7 +7,9 @@ import "github.com/google/uuid"
// Workspace-related events.
var (
TemplateWorkspaceDeleted = uuid.MustParse("f517da0b-cdc9-410f-ab89-a86107c420ed")
WorkspaceAutobuildFailed = uuid.MustParse("381df2a9-c0c0-4749-420f-80a9280c66f9")
WorkspaceAutoUpdated = uuid.MustParse("c34a0c09-0704-4cac-bd1c-0c0146811c2b")
TemplateWorkspaceDeleted = uuid.MustParse("f517da0b-cdc9-410f-ab89-a86107c420ed")
TemplateWorkspaceAutobuildFailed = uuid.MustParse("381df2a9-c0c0-4749-420f-80a9280c66f9")
TemplateWorkspaceDormant = uuid.MustParse("0ea69165-ec14-4314-91f1-69566ac3c5a0")
TemplateWorkspaceAutoUpdated = uuid.MustParse("c34a0c09-0704-4cac-bd1c-0c0146811c2b")
TemplateWorkspaceMarkedForDeletion = uuid.MustParse("51ce2fdf-c9ca-4be1-8d70-628674f9bc42")
)
@@ -98,7 +98,7 @@ type server struct {
TemplateScheduleStore *atomic.Pointer[schedule.TemplateScheduleStore]
UserQuietHoursScheduleStore *atomic.Pointer[schedule.UserQuietHoursScheduleStore]
DeploymentValues *codersdk.DeploymentValues
NotificationEnqueuer notifications.Enqueuer
NotificationsEnqueuer notifications.Enqueuer
OIDCConfig promoauth.OAuth2Config
@@ -202,7 +202,7 @@ func NewServer(
Database: db,
Pubsub: ps,
Acquirer: acquirer,
NotificationEnqueuer: enqueuer,
NotificationsEnqueuer: enqueuer,
Telemetry: tel,
Tracer: tracer,
QuotaCommitter: quotaCommitter,
@@ -1103,7 +1103,7 @@ func (s *server) notifyWorkspaceBuildFailed(ctx context.Context, workspace datab
reason = string(build.Reason)
initiator := "autobuild"
if _, err := s.NotificationEnqueuer.Enqueue(ctx, workspace.OwnerID, notifications.WorkspaceAutobuildFailed,
if _, err := s.NotificationsEnqueuer.Enqueue(ctx, workspace.OwnerID, notifications.TemplateWorkspaceAutobuildFailed,
map[string]string{
"name": workspace.Name,
"initiator": initiator,
@@ -1574,7 +1574,7 @@ func (s *server) notifyWorkspaceDeleted(ctx context.Context, workspace database.
slog.F("reason", reason), slog.F("workspace_id", workspace.ID), slog.F("build_id", build.ID))
}
if _, err := s.NotificationEnqueuer.Enqueue(ctx, workspace.OwnerID, notifications.TemplateWorkspaceDeleted,
if _, err := s.NotificationsEnqueuer.Enqueue(ctx, workspace.OwnerID, notifications.TemplateWorkspaceDeleted,
map[string]string{
"name": workspace.Name,
"reason": reason,
@@ -1601,7 +1601,7 @@ func TestNotifications(t *testing.T) {
t.Parallel()
ctx := context.Background()
notifEnq := &fakeNotificationEnqueuer{}
notifEnq := &testutil.FakeNotificationsEnqueuer{}
srv, db, ps, pd := setup(t, false, &overrides{
notificationEnqueuer: notifEnq,
@@ -1679,17 +1679,17 @@ func TestNotifications(t *testing.T) {
if tc.shouldNotify {
// Validate that the notification was sent and contained the expected values.
require.Len(t, notifEnq.sent, 1)
require.Equal(t, notifEnq.sent[0].userID, user.ID)
require.Contains(t, notifEnq.sent[0].targets, template.ID)
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.Len(t, notifEnq.Sent, 1)
require.Equal(t, notifEnq.Sent[0].UserID, user.ID)
require.Contains(t, notifEnq.Sent[0].Targets, template.ID)
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)
if tc.deletionReason == database.BuildReasonInitiator {
require.Equal(t, initiator.Username, notifEnq.sent[0].labels["initiator"])
require.Equal(t, initiator.Username, notifEnq.Sent[0].Labels["initiator"])
}
} else {
require.Len(t, notifEnq.sent, 0)
require.Len(t, notifEnq.Sent, 0)
}
})
}
@@ -1721,7 +1721,7 @@ func TestNotifications(t *testing.T) {
t.Parallel()
ctx := context.Background()
notifEnq := &fakeNotificationEnqueuer{}
notifEnq := &testutil.FakeNotificationsEnqueuer{}
// Otherwise `(*Server).FailJob` fails with:
// audit log - get build {"error": "sql: no rows in result set"}
@@ -1791,16 +1791,16 @@ func TestNotifications(t *testing.T) {
if tc.shouldNotify {
// Validate that the notification was sent and contained the expected values.
require.Len(t, notifEnq.sent, 1)
require.Equal(t, notifEnq.sent[0].userID, user.ID)
require.Contains(t, notifEnq.sent[0].targets, template.ID)
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"])
require.Len(t, notifEnq.Sent, 1)
require.Equal(t, notifEnq.Sent[0].UserID, user.ID)
require.Contains(t, notifEnq.Sent[0].Targets, template.ID)
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)
require.Len(t, notifEnq.Sent, 0)
}
})
}
@@ -2029,31 +2029,3 @@ func (s *fakeStream) cancel() {
s.canceled = true
s.c.Broadcast()
}
type fakeNotificationEnqueuer struct {
mu sync.Mutex
sent []*notification
}
type notification struct {
userID, templateID uuid.UUID
labels map[string]string
createdBy string
targets []uuid.UUID
}
func (f *fakeNotificationEnqueuer) Enqueue(_ context.Context, userID, templateID uuid.UUID, labels map[string]string, createdBy string, targets ...uuid.UUID) (*uuid.UUID, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.sent = append(f.sent, &notification{
userID: userID,
templateID: templateID,
labels: labels,
createdBy: createdBy,
targets: targets,
})
id := uuid.New()
return &id, nil
}
+1
View File
@@ -12,6 +12,7 @@ import (
"github.com/stretchr/testify/require"
"cdr.dev/slog/sloggers/slogtest"
"github.com/coder/coder/v2/agent/agenttest"
"github.com/coder/coder/v2/coderd/audit"
"github.com/coder/coder/v2/coderd/coderdtest"
+29
View File
@@ -23,6 +23,7 @@ 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/rbac"
@@ -950,6 +951,34 @@ func (api *API) putWorkspaceDormant(rw http.ResponseWriter, r *http.Request) {
return
}
// 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 {
api.Logger.Warn(
ctx,
"failed to fetch the user that marked the workspace",
slog.Error(err),
slog.F("workspace_id", workspace.ID),
slog.F("user_id", apiKey.UserID),
)
} else {
_, err = dormancy.NotifyWorkspaceDormant(
ctx,
api.NotificationsEnqueuer,
dormancy.WorkspaceDormantNotification{
Workspace: workspace,
Initiator: initiator.Username,
Reason: "requested by user",
CreatedBy: "api",
},
)
if err != nil {
api.Logger.Warn(ctx, "failed to notify of workspace marked as dormant", slog.Error(err))
}
}
}
data, err := api.workspaceData(ctx, []database.Workspace{workspace})
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
+117
View File
@@ -30,6 +30,7 @@ import (
"github.com/coder/coder/v2/coderd/database/dbgen"
"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"
"github.com/coder/coder/v2/coderd/rbac/policy"
"github.com/coder/coder/v2/coderd/render"
@@ -3504,3 +3505,119 @@ func TestWorkspaceUsageTracking(t *testing.T) {
require.Greater(t, newWorkspace.LatestBuild.Deadline.Time, workspace.LatestBuild.Deadline.Time)
})
}
func TestNotifications(t *testing.T) {
t.Parallel()
t.Run("Dormant", func(t *testing.T) {
t.Parallel()
t.Run("InitiatorNotOwner", func(t *testing.T) {
t.Parallel()
// Given
var (
notifyEnq = &testutil.FakeNotificationsEnqueuer{}
client = coderdtest.New(t, &coderdtest.Options{
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)
)
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
t.Cleanup(cancel)
// When
err := memberClient.UpdateWorkspaceDormancy(ctx, workspace.ID, codersdk.UpdateWorkspaceDormancy{
Dormant: true,
})
// Then
require.NoError(t, err, "mark workspace as dormant")
require.Len(t, notifyEnq.Sent, 1)
require.Equal(t, notifyEnq.Sent[0].TemplateID, notifications.TemplateWorkspaceDormant)
require.Equal(t, notifyEnq.Sent[0].UserID, workspace.OwnerID)
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"], member.Username)
})
t.Run("InitiatorIsOwner", func(t *testing.T) {
t.Parallel()
// Given
var (
notifyEnq = &testutil.FakeNotificationsEnqueuer{}
client = coderdtest.New(t, &coderdtest.Options{
IncludeProvisionerDaemon: true,
NotificationsEnqueuer: notifyEnq,
})
user = coderdtest.CreateFirstUser(t, client)
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)
t.Cleanup(cancel)
// When
err := client.UpdateWorkspaceDormancy(ctx, workspace.ID, codersdk.UpdateWorkspaceDormancy{
Dormant: true,
})
// Then
require.NoError(t, err, "mark workspace as dormant")
require.Len(t, notifyEnq.Sent, 0)
})
t.Run("ActivateDormantWorkspace", func(t *testing.T) {
t.Parallel()
// Given
var (
notifyEnq = &testutil.FakeNotificationsEnqueuer{}
client = coderdtest.New(t, &coderdtest.Options{
IncludeProvisionerDaemon: true,
NotificationsEnqueuer: notifyEnq,
})
user = coderdtest.CreateFirstUser(t, client)
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)
)
// When
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
t.Cleanup(cancel)
// Make workspace dormant before activate it
err := client.UpdateWorkspaceDormancy(ctx, workspace.ID, codersdk.UpdateWorkspaceDormancy{
Dormant: true,
})
require.NoError(t, err, "mark workspace as dormant")
// Clear notifications before activating the workspace
notifyEnq.Clear()
// Then
err = client.UpdateWorkspaceDormancy(ctx, workspace.ID, codersdk.UpdateWorkspaceDormancy{
Dormant: false,
})
require.NoError(t, err, "mark workspace as active")
require.Len(t, notifyEnq.Sent, 0)
})
})
}