diff --git a/coderd/autobuild/lifecycle_executor.go b/coderd/autobuild/lifecycle_executor.go index 775b5cb803714..082ee0feedfcf 100644 --- a/coderd/autobuild/lifecycle_executor.go +++ b/coderd/autobuild/lifecycle_executor.go @@ -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 { diff --git a/coderd/autobuild/lifecycle_executor_test.go b/coderd/autobuild/lifecycle_executor_test.go index 1821a7610681c..243b2550ccf63 100644 --- a/coderd/autobuild/lifecycle_executor_test.go +++ b/coderd/autobuild/lifecycle_executor_test.go @@ -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: ¬ifyEnq, + 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) diff --git a/coderd/coderdtest/coderdtest.go b/coderd/coderdtest/coderdtest.go index efe9b4fc5208f..d27b392c14343 100644 --- a/coderd/coderdtest/coderdtest.go +++ b/coderd/coderdtest/coderdtest.go @@ -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 { diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 82f26c31da3e6..19e1bb92d2e20 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -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 { diff --git a/coderd/database/dbmem/dbmem.go b/coderd/database/dbmem/dbmem.go index c5eebcfa1f934..cf1773f637a02 100644 --- a/coderd/database/dbmem/dbmem.go +++ b/coderd/database/dbmem/dbmem.go @@ -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 { diff --git a/coderd/database/dbmetrics/dbmetrics.go b/coderd/database/dbmetrics/dbmetrics.go index 312f8e0765d06..e6642da53974f 100644 --- a/coderd/database/dbmetrics/dbmetrics.go +++ b/coderd/database/dbmetrics/dbmetrics.go @@ -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 { diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index 65802bc768eae..8517a7a8e5f21 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -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. diff --git a/coderd/database/migrations/000229_dormancy_notification_template.down.sql b/coderd/database/migrations/000229_dormancy_notification_template.down.sql new file mode 100644 index 0000000000000..ca82cf912c53b --- /dev/null +++ b/coderd/database/migrations/000229_dormancy_notification_template.down.sql @@ -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'; diff --git a/coderd/database/migrations/000229_dormancy_notification_template.up.sql b/coderd/database/migrations/000229_dormancy_notification_template.up.sql new file mode 100644 index 0000000000000..8c8670f163870 --- /dev/null +++ b/coderd/database/migrations/000229_dormancy_notification_template.up.sql @@ -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 + ); diff --git a/coderd/database/querier.go b/coderd/database/querier.go index f680bedcde727..78ebf958739d6 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -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 diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 2b54d1dd96c40..659e24102dd62 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -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 diff --git a/coderd/database/queries/workspaces.sql b/coderd/database/queries/workspaces.sql index ec8767e1f2be5..9b36a99b8c396 100644 --- a/coderd/database/queries/workspaces.sql +++ b/coderd/database/queries/workspaces.sql @@ -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 diff --git a/coderd/dormancy/notifications.go b/coderd/dormancy/notifications.go new file mode 100644 index 0000000000000..162ca272db635 --- /dev/null +++ b/coderd/dormancy/notifications.go @@ -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, + ) +} diff --git a/coderd/notifications/events.go b/coderd/notifications/events.go index 910c571cd6ab0..97c5d19f57a19 100644 --- a/coderd/notifications/events.go +++ b/coderd/notifications/events.go @@ -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") ) diff --git a/coderd/provisionerdserver/provisionerdserver.go b/coderd/provisionerdserver/provisionerdserver.go index e8ec371b1c354..b71935e9a0436 100644 --- a/coderd/provisionerdserver/provisionerdserver.go +++ b/coderd/provisionerdserver/provisionerdserver.go @@ -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, diff --git a/coderd/provisionerdserver/provisionerdserver_test.go b/coderd/provisionerdserver/provisionerdserver_test.go index a0a05e0871152..2117d8e5f3df8 100644 --- a/coderd/provisionerdserver/provisionerdserver_test.go +++ b/coderd/provisionerdserver/provisionerdserver_test.go @@ -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, ¬ification{ - userID: userID, - templateID: templateID, - labels: labels, - createdBy: createdBy, - targets: targets, - }) - - id := uuid.New() - return &id, nil -} diff --git a/coderd/templates_test.go b/coderd/templates_test.go index d89240d801fab..f0decd549c4d3 100644 --- a/coderd/templates_test.go +++ b/coderd/templates_test.go @@ -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" diff --git a/coderd/workspaces.go b/coderd/workspaces.go index 9f1ca970e609e..1f4c4f276a5b8 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -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{ diff --git a/coderd/workspaces_test.go b/coderd/workspaces_test.go index cef7f875fde46..bd158d3893c94 100644 --- a/coderd/workspaces_test.go +++ b/coderd/workspaces_test.go @@ -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) + }) + }) +} diff --git a/enterprise/coderd/coderd.go b/enterprise/coderd/coderd.go index 784695a7ac2e3..787c55ba66d17 100644 --- a/enterprise/coderd/coderd.go +++ b/enterprise/coderd/coderd.go @@ -26,6 +26,7 @@ import ( "github.com/prometheus/client_golang/prometheus" "cdr.dev/slog" + "github.com/coder/coder/v2/coderd" agplaudit "github.com/coder/coder/v2/coderd/audit" agpldbauthz "github.com/coder/coder/v2/coderd/database/dbauthz" @@ -649,7 +650,7 @@ func (api *API) updateEntitlements(ctx context.Context) error { if initial, changed, enabled := featureChanged(codersdk.FeatureAdvancedTemplateScheduling); shouldUpdate(initial, changed, enabled) { if enabled { - templateStore := schedule.NewEnterpriseTemplateScheduleStore(api.AGPL.UserQuietHoursScheduleStore) + templateStore := schedule.NewEnterpriseTemplateScheduleStore(api.AGPL.UserQuietHoursScheduleStore, api.NotificationsEnqueuer, api.Logger.Named("template.schedule-store")) templateStoreInterface := agplschedule.TemplateScheduleStore(templateStore) api.AGPL.TemplateScheduleStore.Store(&templateStoreInterface) diff --git a/enterprise/coderd/schedule/template.go b/enterprise/coderd/schedule/template.go index 5d5a786020241..c3cb5001e091c 100644 --- a/enterprise/coderd/schedule/template.go +++ b/enterprise/coderd/schedule/template.go @@ -6,6 +6,8 @@ import ( "sync/atomic" "time" + "cdr.dev/slog" + "github.com/google/uuid" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/trace" @@ -14,6 +16,8 @@ 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" "github.com/coder/coder/v2/codersdk" @@ -28,13 +32,18 @@ type EnterpriseTemplateScheduleStore struct { // Custom time.Now() function to use in tests. Defaults to dbtime.Now(). TimeNowFn func() time.Time + + enqueuer notifications.Enqueuer + logger slog.Logger } var _ agpl.TemplateScheduleStore = &EnterpriseTemplateScheduleStore{} -func NewEnterpriseTemplateScheduleStore(userQuietHoursStore *atomic.Pointer[agpl.UserQuietHoursScheduleStore]) *EnterpriseTemplateScheduleStore { +func NewEnterpriseTemplateScheduleStore(userQuietHoursStore *atomic.Pointer[agpl.UserQuietHoursScheduleStore], enqueuer notifications.Enqueuer, logger slog.Logger) *EnterpriseTemplateScheduleStore { return &EnterpriseTemplateScheduleStore{ UserQuietHoursScheduleStore: userQuietHoursStore, + enqueuer: enqueuer, + logger: logger, } } @@ -125,7 +134,10 @@ func (s *EnterpriseTemplateScheduleStore) Set(ctx context.Context, db database.S return database.Template{}, xerrors.Errorf("verify autostart requirement: %w", err) } - var template database.Template + var ( + template database.Template + markedForDeletion []database.Workspace + ) err = db.InTx(func(tx database.Store) error { ctx, span := tracing.StartSpanWithName(ctx, "(*schedule.EnterpriseTemplateScheduleStore).Set()-InTx()") defer span.End() @@ -159,7 +171,7 @@ func (s *EnterpriseTemplateScheduleStore) Set(ctx context.Context, db database.S // to ensure workspaces are being cleaned up correctly. Similarly if we are // disabling it (by passing 0), then we want to delete nullify the deleting_at // fields of all the template workspaces. - err = tx.UpdateWorkspacesDormantDeletingAtByTemplateID(ctx, database.UpdateWorkspacesDormantDeletingAtByTemplateIDParams{ + markedForDeletion, err = tx.UpdateWorkspacesDormantDeletingAtByTemplateID(ctx, database.UpdateWorkspacesDormantDeletingAtByTemplateIDParams{ TemplateID: tpl.ID, TimeTilDormantAutodeleteMs: opts.TimeTilDormantAutoDelete.Milliseconds(), DormantAt: dormantAt, @@ -193,6 +205,21 @@ func (s *EnterpriseTemplateScheduleStore) Set(ctx context.Context, db database.S return database.Template{}, err } + for _, workspace := range markedForDeletion { + _, err = dormancy.NotifyWorkspaceMarkedForDeletion( + ctx, + s.enqueuer, + dormancy.WorkspaceMarkedForDeletionNotification{ + Workspace: workspace, + Reason: "template updated to new dormancy policy", + CreatedBy: "scheduletemplate", + }, + ) + if err != nil { + s.logger.Warn(ctx, "failed to notify of workspace marked for deletion", slog.Error(err), slog.F("workspace_id", workspace.ID)) + } + } + return template, nil } diff --git a/enterprise/coderd/schedule/template_test.go b/enterprise/coderd/schedule/template_test.go index dd60805f00197..bce5ffbec930e 100644 --- a/enterprise/coderd/schedule/template_test.go +++ b/enterprise/coderd/schedule/template_test.go @@ -12,9 +12,13 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "cdr.dev/slog" + "cdr.dev/slog/sloggers/slogtest" + "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/dbgen" "github.com/coder/coder/v2/coderd/database/dbtestutil" + "github.com/coder/coder/v2/coderd/notifications" agplschedule "github.com/coder/coder/v2/coderd/schedule" "github.com/coder/coder/v2/coderd/util/ptr" "github.com/coder/coder/v2/cryptorand" @@ -270,13 +274,15 @@ func TestTemplateUpdateBuildDeadlines(t *testing.T) { wsBuild, err = db.GetWorkspaceBuildByID(ctx, wsBuild.ID) require.NoError(t, err) + logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug) + userQuietHoursStore, err := schedule.NewEnterpriseUserQuietHoursScheduleStore(userQuietHoursSchedule, true) require.NoError(t, err) userQuietHoursStorePtr := &atomic.Pointer[agplschedule.UserQuietHoursScheduleStore]{} userQuietHoursStorePtr.Store(&userQuietHoursStore) // Set the template policy. - templateScheduleStore := schedule.NewEnterpriseTemplateScheduleStore(userQuietHoursStorePtr) + templateScheduleStore := schedule.NewEnterpriseTemplateScheduleStore(userQuietHoursStorePtr, notifications.NewNoopEnqueuer(), logger) templateScheduleStore.TimeNowFn = func() time.Time { return c.now } @@ -555,13 +561,15 @@ func TestTemplateUpdateBuildDeadlinesSkip(t *testing.T) { require.NoError(t, err) } + logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug) + userQuietHoursStore, err := schedule.NewEnterpriseUserQuietHoursScheduleStore(userQuietHoursSchedule, true) require.NoError(t, err) userQuietHoursStorePtr := &atomic.Pointer[agplschedule.UserQuietHoursScheduleStore]{} userQuietHoursStorePtr.Store(&userQuietHoursStore) // Set the template policy. - templateScheduleStore := schedule.NewEnterpriseTemplateScheduleStore(userQuietHoursStorePtr) + templateScheduleStore := schedule.NewEnterpriseTemplateScheduleStore(userQuietHoursStorePtr, notifications.NewNoopEnqueuer(), logger) templateScheduleStore.TimeNowFn = func() time.Time { return now } @@ -598,6 +606,104 @@ func TestTemplateUpdateBuildDeadlinesSkip(t *testing.T) { } } +func TestNotifications(t *testing.T) { + t.Parallel() + + t.Run("Dormancy", func(t *testing.T) { + t.Parallel() + + var ( + db, _ = dbtestutil.NewDB(t) + ctx = testutil.Context(t, testutil.WaitLong) + user = dbgen.User(t, db, database.User{}) + file = dbgen.File(t, db, database.File{ + CreatedBy: user.ID, + }) + templateJob = dbgen.ProvisionerJob(t, db, nil, database.ProvisionerJob{ + FileID: file.ID, + InitiatorID: user.ID, + Tags: database.StringMap{ + "foo": "bar", + }, + }) + timeTilDormant = time.Minute * 2 + templateVersion = dbgen.TemplateVersion(t, db, database.TemplateVersion{ + CreatedBy: user.ID, + JobID: templateJob.ID, + OrganizationID: templateJob.OrganizationID, + }) + template = dbgen.Template(t, db, database.Template{ + ActiveVersionID: templateVersion.ID, + CreatedBy: user.ID, + OrganizationID: templateJob.OrganizationID, + TimeTilDormant: int64(timeTilDormant), + TimeTilDormantAutoDelete: int64(timeTilDormant), + }) + ) + + // Add two dormant workspaces and one active workspace. + dormantWorkspaces := []database.Workspace{ + dbgen.Workspace(t, db, database.Workspace{ + OwnerID: user.ID, + TemplateID: template.ID, + OrganizationID: templateJob.OrganizationID, + LastUsedAt: time.Now().Add(-time.Hour), + }), + dbgen.Workspace(t, db, database.Workspace{ + OwnerID: user.ID, + TemplateID: template.ID, + OrganizationID: templateJob.OrganizationID, + LastUsedAt: time.Now().Add(-time.Hour), + }), + } + dbgen.Workspace(t, db, database.Workspace{ + OwnerID: user.ID, + TemplateID: template.ID, + OrganizationID: templateJob.OrganizationID, + LastUsedAt: time.Now(), + }) + for _, ws := range dormantWorkspaces { + db.UpdateWorkspaceDormantDeletingAt(ctx, database.UpdateWorkspaceDormantDeletingAtParams{ + ID: ws.ID, + DormantAt: sql.NullTime{ + Time: ws.LastUsedAt.Add(timeTilDormant), + Valid: true, + }, + }) + } + + // Setup dependencies + notifyEnq := testutil.FakeNotificationsEnqueuer{} + logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug) + const userQuietHoursSchedule = "CRON_TZ=UTC 0 0 * * *" // midnight UTC + userQuietHoursStore, err := schedule.NewEnterpriseUserQuietHoursScheduleStore(userQuietHoursSchedule, true) + require.NoError(t, err) + userQuietHoursStorePtr := &atomic.Pointer[agplschedule.UserQuietHoursScheduleStore]{} + userQuietHoursStorePtr.Store(&userQuietHoursStore) + templateScheduleStore := schedule.NewEnterpriseTemplateScheduleStore(userQuietHoursStorePtr, ¬ifyEnq, logger) + templateScheduleStore.TimeNowFn = time.Now + + // Lower the dormancy TTL to ensure the schedule recalculates deadlines and + // triggers notifications. + _, err = templateScheduleStore.Set(ctx, db, template, agplschedule.TemplateScheduleOptions{ + TimeTilDormant: timeTilDormant / 2, + TimeTilDormantAutoDelete: timeTilDormant / 2, + }) + require.NoError(t, err) + + // We should expect a notification for each dormant workspace. + require.Len(t, notifyEnq.Sent, len(dormantWorkspaces)) + for i, dormantWs := range dormantWorkspaces { + require.Equal(t, notifyEnq.Sent[i].UserID, dormantWs.OwnerID) + require.Equal(t, notifyEnq.Sent[i].TemplateID, notifications.TemplateWorkspaceMarkedForDeletion) + require.Contains(t, notifyEnq.Sent[i].Targets, template.ID) + require.Contains(t, notifyEnq.Sent[i].Targets, dormantWs.ID) + require.Contains(t, notifyEnq.Sent[i].Targets, dormantWs.OrganizationID) + require.Contains(t, notifyEnq.Sent[i].Targets, dormantWs.OwnerID) + } + }) +} + func must[V any](v V, err error) V { if err != nil { panic(err) diff --git a/enterprise/coderd/templates_test.go b/enterprise/coderd/templates_test.go index 5d44023af86b9..d817698ef75f8 100644 --- a/enterprise/coderd/templates_test.go +++ b/enterprise/coderd/templates_test.go @@ -11,9 +11,13 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "cdr.dev/slog" + "cdr.dev/slog/sloggers/slogtest" + "github.com/coder/coder/v2/coderd/audit" "github.com/coder/coder/v2/coderd/coderdtest" "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/notifications" "github.com/coder/coder/v2/coderd/rbac" "github.com/coder/coder/v2/coderd/util/ptr" "github.com/coder/coder/v2/codersdk" @@ -29,6 +33,8 @@ import ( func TestTemplates(t *testing.T) { t.Parallel() + logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug) + t.Run("Deprecated", func(t *testing.T) { t.Parallel() @@ -637,7 +643,7 @@ func TestTemplates(t *testing.T) { client, user := coderdenttest.New(t, &coderdenttest.Options{ Options: &coderdtest.Options{ IncludeProvisionerDaemon: true, - TemplateScheduleStore: schedule.NewEnterpriseTemplateScheduleStore(agplUserQuietHoursScheduleStore()), + TemplateScheduleStore: schedule.NewEnterpriseTemplateScheduleStore(agplUserQuietHoursScheduleStore(), notifications.NewNoopEnqueuer(), logger), }, LicenseOptions: &coderdenttest.LicenseOptions{ Features: license.Features{ @@ -687,7 +693,7 @@ func TestTemplates(t *testing.T) { owner, first := coderdenttest.New(t, &coderdenttest.Options{ Options: &coderdtest.Options{ IncludeProvisionerDaemon: true, - TemplateScheduleStore: schedule.NewEnterpriseTemplateScheduleStore(agplUserQuietHoursScheduleStore()), + TemplateScheduleStore: schedule.NewEnterpriseTemplateScheduleStore(agplUserQuietHoursScheduleStore(), notifications.NewNoopEnqueuer(), logger), }, LicenseOptions: &coderdenttest.LicenseOptions{ Features: license.Features{ diff --git a/enterprise/coderd/workspaces_test.go b/enterprise/coderd/workspaces_test.go index 9cb86f55ba55f..11923e6889cd0 100644 --- a/enterprise/coderd/workspaces_test.go +++ b/enterprise/coderd/workspaces_test.go @@ -9,6 +9,8 @@ import ( "github.com/stretchr/testify/require" + "cdr.dev/slog" + "cdr.dev/slog/sloggers/slogtest" "github.com/coder/coder/v2/coderd/audit" @@ -17,6 +19,7 @@ import ( "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/dbfake" "github.com/coder/coder/v2/coderd/database/dbtestutil" + "github.com/coder/coder/v2/coderd/notifications" "github.com/coder/coder/v2/coderd/rbac" agplschedule "github.com/coder/coder/v2/coderd/schedule" "github.com/coder/coder/v2/coderd/schedule/cron" @@ -118,7 +121,7 @@ func TestWorkspaceAutobuild(t *testing.T) { AutobuildTicker: ticker, IncludeProvisionerDaemon: true, AutobuildStats: statCh, - TemplateScheduleStore: schedule.NewEnterpriseTemplateScheduleStore(agplUserQuietHoursScheduleStore()), + TemplateScheduleStore: schedule.NewEnterpriseTemplateScheduleStore(agplUserQuietHoursScheduleStore(), notifications.NewNoopEnqueuer(), logger), }, LicenseOptions: &coderdenttest.LicenseOptions{ Features: license.Features{codersdk.FeatureAdvancedTemplateScheduling: 1}, @@ -165,7 +168,7 @@ func TestWorkspaceAutobuild(t *testing.T) { AutobuildTicker: ticker, IncludeProvisionerDaemon: true, AutobuildStats: statCh, - TemplateScheduleStore: schedule.NewEnterpriseTemplateScheduleStore(agplUserQuietHoursScheduleStore()), + TemplateScheduleStore: schedule.NewEnterpriseTemplateScheduleStore(agplUserQuietHoursScheduleStore(), notifications.NewNoopEnqueuer(), logger), }, LicenseOptions: &coderdenttest.LicenseOptions{ Features: license.Features{codersdk.FeatureAdvancedTemplateScheduling: 1}, @@ -211,7 +214,7 @@ func TestWorkspaceAutobuild(t *testing.T) { AutobuildTicker: ticker, IncludeProvisionerDaemon: true, AutobuildStats: statCh, - TemplateScheduleStore: schedule.NewEnterpriseTemplateScheduleStore(agplUserQuietHoursScheduleStore()), + TemplateScheduleStore: schedule.NewEnterpriseTemplateScheduleStore(agplUserQuietHoursScheduleStore(), notifications.NewNoopEnqueuer(), logger), }, LicenseOptions: &coderdenttest.LicenseOptions{ Features: license.Features{codersdk.FeatureAdvancedTemplateScheduling: 1}, @@ -249,11 +252,13 @@ func TestWorkspaceAutobuild(t *testing.T) { auditRecorder = audit.NewMock() ) + logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug) + client, db, user := coderdenttest.NewWithDatabase(t, &coderdenttest.Options{ Options: &coderdtest.Options{ AutobuildTicker: ticker, AutobuildStats: statCh, - TemplateScheduleStore: schedule.NewEnterpriseTemplateScheduleStore(agplUserQuietHoursScheduleStore()), + TemplateScheduleStore: schedule.NewEnterpriseTemplateScheduleStore(agplUserQuietHoursScheduleStore(), notifications.NewNoopEnqueuer(), logger), Auditor: auditRecorder, }, LicenseOptions: &coderdenttest.LicenseOptions{ @@ -342,12 +347,13 @@ func TestWorkspaceAutobuild(t *testing.T) { // another connection from within a transaction. sdb.SetMaxOpenConns(maxConns) auditor := entaudit.NewAuditor(db, entaudit.DefaultFilter, backends.NewPostgres(db, true)) + logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug) client, user := coderdenttest.New(t, &coderdenttest.Options{ Options: &coderdtest.Options{ AutobuildTicker: ticker, AutobuildStats: statCh, - TemplateScheduleStore: schedule.NewEnterpriseTemplateScheduleStore(agplUserQuietHoursScheduleStore()), + TemplateScheduleStore: schedule.NewEnterpriseTemplateScheduleStore(agplUserQuietHoursScheduleStore(), notifications.NewNoopEnqueuer(), logger), Database: db, Pubsub: pubsub, Auditor: auditor, @@ -399,12 +405,13 @@ func TestWorkspaceAutobuild(t *testing.T) { inactiveTTL = time.Minute ) + logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug) client, user := coderdenttest.New(t, &coderdenttest.Options{ Options: &coderdtest.Options{ AutobuildTicker: ticker, IncludeProvisionerDaemon: true, AutobuildStats: statCh, - TemplateScheduleStore: schedule.NewEnterpriseTemplateScheduleStore(agplUserQuietHoursScheduleStore()), + TemplateScheduleStore: schedule.NewEnterpriseTemplateScheduleStore(agplUserQuietHoursScheduleStore(), notifications.NewNoopEnqueuer(), logger), }, LicenseOptions: &coderdenttest.LicenseOptions{ Features: license.Features{codersdk.FeatureAdvancedTemplateScheduling: 1}, @@ -441,12 +448,13 @@ func TestWorkspaceAutobuild(t *testing.T) { autoDeleteTTL = time.Minute ) + logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug) client, user := coderdenttest.New(t, &coderdenttest.Options{ Options: &coderdtest.Options{ AutobuildTicker: ticker, IncludeProvisionerDaemon: true, AutobuildStats: statCh, - TemplateScheduleStore: schedule.NewEnterpriseTemplateScheduleStore(agplUserQuietHoursScheduleStore()), + TemplateScheduleStore: schedule.NewEnterpriseTemplateScheduleStore(agplUserQuietHoursScheduleStore(), notifications.NewNoopEnqueuer(), logger), }, LicenseOptions: &coderdenttest.LicenseOptions{ Features: license.Features{codersdk.FeatureAdvancedTemplateScheduling: 1}, @@ -483,12 +491,13 @@ func TestWorkspaceAutobuild(t *testing.T) { inactiveTTL = time.Minute ) + logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug) client, user := coderdenttest.New(t, &coderdenttest.Options{ Options: &coderdtest.Options{ AutobuildTicker: ticker, IncludeProvisionerDaemon: true, AutobuildStats: statCh, - TemplateScheduleStore: schedule.NewEnterpriseTemplateScheduleStore(agplUserQuietHoursScheduleStore()), + TemplateScheduleStore: schedule.NewEnterpriseTemplateScheduleStore(agplUserQuietHoursScheduleStore(), notifications.NewNoopEnqueuer(), logger), }, LicenseOptions: &coderdenttest.LicenseOptions{ Features: license.Features{codersdk.FeatureAdvancedTemplateScheduling: 1}, @@ -536,12 +545,13 @@ func TestWorkspaceAutobuild(t *testing.T) { transitionTTL = time.Minute ) + logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug) client, user := coderdenttest.New(t, &coderdenttest.Options{ Options: &coderdtest.Options{ AutobuildTicker: ticker, IncludeProvisionerDaemon: true, AutobuildStats: statCh, - TemplateScheduleStore: schedule.NewEnterpriseTemplateScheduleStore(agplUserQuietHoursScheduleStore()), + TemplateScheduleStore: schedule.NewEnterpriseTemplateScheduleStore(agplUserQuietHoursScheduleStore(), notifications.NewNoopEnqueuer(), logger), }, LicenseOptions: &coderdenttest.LicenseOptions{ Features: license.Features{codersdk.FeatureAdvancedTemplateScheduling: 1}, @@ -607,12 +617,13 @@ func TestWorkspaceAutobuild(t *testing.T) { dormantTTL = time.Minute ) + logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug) client, user := coderdenttest.New(t, &coderdenttest.Options{ Options: &coderdtest.Options{ AutobuildTicker: ticker, IncludeProvisionerDaemon: true, AutobuildStats: statCh, - TemplateScheduleStore: schedule.NewEnterpriseTemplateScheduleStore(agplUserQuietHoursScheduleStore()), + TemplateScheduleStore: schedule.NewEnterpriseTemplateScheduleStore(agplUserQuietHoursScheduleStore(), notifications.NewNoopEnqueuer(), logger), }, LicenseOptions: &coderdenttest.LicenseOptions{ Features: license.Features{codersdk.FeatureAdvancedTemplateScheduling: 1}, @@ -669,12 +680,14 @@ func TestWorkspaceAutobuild(t *testing.T) { statsCh = make(chan autobuild.Stats) inactiveTTL = time.Minute ) + + logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug) client, user := coderdenttest.New(t, &coderdenttest.Options{ Options: &coderdtest.Options{ AutobuildTicker: tickCh, IncludeProvisionerDaemon: true, AutobuildStats: statsCh, - TemplateScheduleStore: schedule.NewEnterpriseTemplateScheduleStore(agplUserQuietHoursScheduleStore()), + TemplateScheduleStore: schedule.NewEnterpriseTemplateScheduleStore(agplUserQuietHoursScheduleStore(), notifications.NewNoopEnqueuer(), logger), }, LicenseOptions: &coderdenttest.LicenseOptions{ Features: license.Features{codersdk.FeatureAdvancedTemplateScheduling: 1}, @@ -748,12 +761,13 @@ func TestWorkspaceAutobuild(t *testing.T) { ctx = testutil.Context(t, testutil.WaitMedium) ) + logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug) client, user := coderdenttest.New(t, &coderdenttest.Options{ Options: &coderdtest.Options{ AutobuildTicker: ticker, IncludeProvisionerDaemon: true, AutobuildStats: statCh, - TemplateScheduleStore: schedule.NewEnterpriseTemplateScheduleStore(agplUserQuietHoursScheduleStore()), + TemplateScheduleStore: schedule.NewEnterpriseTemplateScheduleStore(agplUserQuietHoursScheduleStore(), notifications.NewNoopEnqueuer(), logger), }, LicenseOptions: &coderdenttest.LicenseOptions{ Features: license.Features{codersdk.FeatureAdvancedTemplateScheduling: 1}, @@ -833,12 +847,13 @@ func TestWorkspaceAutobuild(t *testing.T) { ctx = testutil.Context(t, testutil.WaitMedium) ) + logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug) client, user := coderdenttest.New(t, &coderdenttest.Options{ Options: &coderdtest.Options{ AutobuildTicker: tickCh, IncludeProvisionerDaemon: true, AutobuildStats: statsCh, - TemplateScheduleStore: schedule.NewEnterpriseTemplateScheduleStore(agplUserQuietHoursScheduleStore()), + TemplateScheduleStore: schedule.NewEnterpriseTemplateScheduleStore(agplUserQuietHoursScheduleStore(), notifications.NewNoopEnqueuer(), logger), }, LicenseOptions: &coderdenttest.LicenseOptions{ Features: license.Features{codersdk.FeatureAccessControl: 1}, @@ -920,9 +935,10 @@ func TestTemplateDoesNotAllowUserAutostop(t *testing.T) { t.Run("TTLSetByTemplate", func(t *testing.T) { t.Parallel() + logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug) client := coderdtest.New(t, &coderdtest.Options{ IncludeProvisionerDaemon: true, - TemplateScheduleStore: schedule.NewEnterpriseTemplateScheduleStore(agplUserQuietHoursScheduleStore()), + TemplateScheduleStore: schedule.NewEnterpriseTemplateScheduleStore(agplUserQuietHoursScheduleStore(), notifications.NewNoopEnqueuer(), logger), }) user := coderdtest.CreateFirstUser(t, client) version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) @@ -958,9 +974,10 @@ func TestTemplateDoesNotAllowUserAutostop(t *testing.T) { t.Run("ExtendIsNotEnabledByTemplate", func(t *testing.T) { t.Parallel() + logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug) client := coderdtest.New(t, &coderdtest.Options{ IncludeProvisionerDaemon: true, - TemplateScheduleStore: schedule.NewEnterpriseTemplateScheduleStore(agplUserQuietHoursScheduleStore()), + TemplateScheduleStore: schedule.NewEnterpriseTemplateScheduleStore(agplUserQuietHoursScheduleStore(), notifications.NewNoopEnqueuer(), logger), }) user := coderdtest.CreateFirstUser(t, client) version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) @@ -1002,15 +1019,17 @@ func TestExecutorAutostartBlocked(t *testing.T) { } var ( - sched = must(cron.Weekly("CRON_TZ=UTC 0 * * * *")) - tickCh = make(chan time.Time) - statsCh = make(chan autobuild.Stats) + sched = must(cron.Weekly("CRON_TZ=UTC 0 * * * *")) + tickCh = make(chan time.Time) + statsCh = make(chan autobuild.Stats) + + logger = slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug) client, owner = coderdenttest.New(t, &coderdenttest.Options{ Options: &coderdtest.Options{ AutobuildTicker: tickCh, IncludeProvisionerDaemon: true, AutobuildStats: statsCh, - TemplateScheduleStore: schedule.NewEnterpriseTemplateScheduleStore(agplUserQuietHoursScheduleStore()), + TemplateScheduleStore: schedule.NewEnterpriseTemplateScheduleStore(agplUserQuietHoursScheduleStore(), notifications.NewNoopEnqueuer(), logger), }, LicenseOptions: &coderdenttest.LicenseOptions{ Features: license.Features{codersdk.FeatureAdvancedTemplateScheduling: 1}, @@ -1051,9 +1070,10 @@ func TestWorkspacesFiltering(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitMedium) + logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug) client, db, owner := coderdenttest.NewWithDatabase(t, &coderdenttest.Options{ Options: &coderdtest.Options{ - TemplateScheduleStore: schedule.NewEnterpriseTemplateScheduleStore(agplUserQuietHoursScheduleStore()), + TemplateScheduleStore: schedule.NewEnterpriseTemplateScheduleStore(agplUserQuietHoursScheduleStore(), notifications.NewNoopEnqueuer(), logger), }, LicenseOptions: &coderdenttest.LicenseOptions{ Features: license.Features{codersdk.FeatureAdvancedTemplateScheduling: 1}, diff --git a/testutil/notifications.go b/testutil/notifications.go index 44ce209898197..a8d6486209d2a 100644 --- a/testutil/notifications.go +++ b/testutil/notifications.go @@ -7,9 +7,8 @@ import ( "github.com/google/uuid" ) -type FakeNotificationEnqueuer struct { - mu sync.Mutex - +type FakeNotificationsEnqueuer struct { + mu sync.Mutex Sent []*Notification } @@ -20,7 +19,7 @@ type Notification struct { 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) { +func (f *FakeNotificationsEnqueuer) 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() @@ -35,3 +34,10 @@ func (f *FakeNotificationEnqueuer) Enqueue(_ context.Context, userID, templateID id := uuid.New() return &id, nil } + +func (f *FakeNotificationsEnqueuer) Clear() { + f.mu.Lock() + defer f.mu.Unlock() + + f.Sent = nil +}