feat(coderd): notify when workspace is marked as dormant (#13868)
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
);
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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, ¬ification{
|
||||
userID: userID,
|
||||
templateID: templateID,
|
||||
labels: labels,
|
||||
createdBy: createdBy,
|
||||
targets: targets,
|
||||
})
|
||||
|
||||
id := uuid.New()
|
||||
return &id, nil
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user