Skip to content

Commit fbd1d7f

Browse files
authored
feat: notify on successful autoupdate (coder#13903)
1 parent 44924cd commit fbd1d7f

File tree

8 files changed

+130
-11
lines changed

8 files changed

+130
-11
lines changed

cli/server.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1066,7 +1066,7 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd.
10661066
autobuildTicker := time.NewTicker(vals.AutobuildPollInterval.Value())
10671067
defer autobuildTicker.Stop()
10681068
autobuildExecutor := autobuild.NewExecutor(
1069-
ctx, options.Database, options.Pubsub, coderAPI.TemplateScheduleStore, &coderAPI.Auditor, coderAPI.AccessControlStore, logger, autobuildTicker.C)
1069+
ctx, options.Database, options.Pubsub, coderAPI.TemplateScheduleStore, &coderAPI.Auditor, coderAPI.AccessControlStore, logger, autobuildTicker.C, options.NotificationsEnqueuer)
10701070
autobuildExecutor.Run()
10711071

10721072
hangDetectorTicker := time.NewTicker(vals.JobHangDetectorInterval.Value())

coderd/autobuild/lifecycle_executor.go

Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import (
1919
"github.com/coder/coder/v2/coderd/database/dbtime"
2020
"github.com/coder/coder/v2/coderd/database/provisionerjobs"
2121
"github.com/coder/coder/v2/coderd/database/pubsub"
22+
"github.com/coder/coder/v2/coderd/notifications"
2223
"github.com/coder/coder/v2/coderd/schedule"
2324
"github.com/coder/coder/v2/coderd/wsbuilder"
2425
)
@@ -34,6 +35,9 @@ type Executor struct {
3435
log slog.Logger
3536
tick <-chan time.Time
3637
statsCh chan<- Stats
38+
39+
// NotificationsEnqueuer handles enqueueing notifications for delivery by SMTP, webhook, etc.
40+
notificationsEnqueuer notifications.Enqueuer
3741
}
3842

3943
// Stats contains information about one run of Executor.
@@ -44,7 +48,7 @@ type Stats struct {
4448
}
4549

4650
// New returns a new wsactions executor.
47-
func NewExecutor(ctx context.Context, db database.Store, ps pubsub.Pubsub, tss *atomic.Pointer[schedule.TemplateScheduleStore], auditor *atomic.Pointer[audit.Auditor], acs *atomic.Pointer[dbauthz.AccessControlStore], log slog.Logger, tick <-chan time.Time) *Executor {
51+
func NewExecutor(ctx context.Context, db database.Store, ps pubsub.Pubsub, tss *atomic.Pointer[schedule.TemplateScheduleStore], auditor *atomic.Pointer[audit.Auditor], acs *atomic.Pointer[dbauthz.AccessControlStore], log slog.Logger, tick <-chan time.Time, enqueuer notifications.Enqueuer) *Executor {
4852
le := &Executor{
4953
//nolint:gocritic // Autostart has a limited set of permissions.
5054
ctx: dbauthz.AsAutostart(ctx),
@@ -55,6 +59,7 @@ func NewExecutor(ctx context.Context, db database.Store, ps pubsub.Pubsub, tss *
5559
log: log.Named("autobuild"),
5660
auditor: auditor,
5761
accessControlStore: acs,
62+
notificationsEnqueuer: enqueuer,
5863
}
5964
return le
6065
}
@@ -138,11 +143,18 @@ func (e *Executor) runOnce(t time.Time) Stats {
138143
eg.Go(func() error {
139144
err := func() error {
140145
var job *database.ProvisionerJob
146+
var nextBuild *database.WorkspaceBuild
147+
var activeTemplateVersion database.TemplateVersion
148+
var ws database.Workspace
149+
141150
var auditLog *auditParams
151+
var didAutoUpdate bool
142152
err := e.db.InTx(func(tx database.Store) error {
153+
var err error
154+
143155
// Re-check eligibility since the first check was outside the
144156
// transaction and the workspace settings may have changed.
145-
ws, err := tx.GetWorkspaceByID(e.ctx, wsID)
157+
ws, err = tx.GetWorkspaceByID(e.ctx, wsID)
146158
if err != nil {
147159
return xerrors.Errorf("get workspace by id: %w", err)
148160
}
@@ -173,6 +185,11 @@ func (e *Executor) runOnce(t time.Time) Stats {
173185
return xerrors.Errorf("get template by ID: %w", err)
174186
}
175187

188+
activeTemplateVersion, err = tx.GetTemplateVersionByID(e.ctx, template.ActiveVersionID)
189+
if err != nil {
190+
return xerrors.Errorf("get active template version by ID: %w", err)
191+
}
192+
176193
accessControl := (*(e.accessControlStore.Load())).GetTemplateAccessControl(template)
177194

178195
nextTransition, reason, err := getNextTransition(user, ws, latestBuild, latestJob, templateSchedule, currentTick)
@@ -195,9 +212,15 @@ func (e *Executor) runOnce(t time.Time) Stats {
195212
useActiveVersion(accessControl, ws) {
196213
log.Debug(e.ctx, "autostarting with active version")
197214
builder = builder.ActiveVersion()
215+
216+
if latestBuild.TemplateVersionID != template.ActiveVersionID {
217+
// control flag to know if the workspace was auto-updated,
218+
// so the lifecycle executor can notify the user
219+
didAutoUpdate = true
220+
}
198221
}
199222

200-
_, job, err = builder.Build(e.ctx, tx, nil, audit.WorkspaceBuildBaggage{IP: "127.0.0.1"})
223+
nextBuild, job, err = builder.Build(e.ctx, tx, nil, audit.WorkspaceBuildBaggage{IP: "127.0.0.1"})
201224
if err != nil {
202225
return xerrors.Errorf("build workspace with transition %q: %w", nextTransition, err)
203226
}
@@ -261,6 +284,25 @@ func (e *Executor) runOnce(t time.Time) Stats {
261284
auditLog.Success = err == nil
262285
auditBuild(e.ctx, log, *e.auditor.Load(), *auditLog)
263286
}
287+
if didAutoUpdate && err == nil {
288+
nextBuildReason := ""
289+
if nextBuild != nil {
290+
nextBuildReason = string(nextBuild.Reason)
291+
}
292+
293+
if _, err := e.notificationsEnqueuer.Enqueue(e.ctx, ws.OwnerID, notifications.WorkspaceAutoUpdated,
294+
map[string]string{
295+
"name": ws.Name,
296+
"initiator": "autobuild",
297+
"reason": nextBuildReason,
298+
"template_version_name": activeTemplateVersion.Name,
299+
}, "autobuild",
300+
// Associate this notification with all the related entities.
301+
ws.ID, ws.OwnerID, ws.TemplateID, ws.OrganizationID,
302+
); err != nil {
303+
log.Warn(e.ctx, "failed to notify of autoupdated workspace", slog.Error(err))
304+
}
305+
}
264306
if err != nil {
265307
return xerrors.Errorf("transition workspace: %w", err)
266308
}

coderd/autobuild/lifecycle_executor_test.go

Lines changed: 26 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import (
1818
"github.com/coder/coder/v2/coderd/coderdtest"
1919
"github.com/coder/coder/v2/coderd/database"
2020
"github.com/coder/coder/v2/coderd/database/dbauthz"
21+
"github.com/coder/coder/v2/coderd/notifications/notiffake"
2122
"github.com/coder/coder/v2/coderd/schedule"
2223
"github.com/coder/coder/v2/coderd/schedule/cron"
2324
"github.com/coder/coder/v2/coderd/util/ptr"
@@ -79,6 +80,7 @@ func TestExecutorAutostartTemplateUpdated(t *testing.T) {
7980
compatibleParameters bool
8081
expectStart bool
8182
expectUpdate bool
83+
expectNotification bool
8284
}{
8385
{
8486
name: "Never",
@@ -93,6 +95,7 @@ func TestExecutorAutostartTemplateUpdated(t *testing.T) {
9395
compatibleParameters: true,
9496
expectStart: true,
9597
expectUpdate: true,
98+
expectNotification: true,
9699
},
97100
{
98101
name: "Always_Incompatible",
@@ -107,17 +110,19 @@ func TestExecutorAutostartTemplateUpdated(t *testing.T) {
107110
t.Run(tc.name, func(t *testing.T) {
108111
t.Parallel()
109112
var (
110-
sched = mustSchedule(t, "CRON_TZ=UTC 0 * * * *")
111-
ctx = context.Background()
112-
err error
113-
tickCh = make(chan time.Time)
114-
statsCh = make(chan autobuild.Stats)
115-
logger = slogtest.Make(t, &slogtest.Options{IgnoreErrors: !tc.expectStart}).Leveled(slog.LevelDebug)
116-
client = coderdtest.New(t, &coderdtest.Options{
113+
sched = mustSchedule(t, "CRON_TZ=UTC 0 * * * *")
114+
ctx = context.Background()
115+
err error
116+
tickCh = make(chan time.Time)
117+
statsCh = make(chan autobuild.Stats)
118+
logger = slogtest.Make(t, &slogtest.Options{IgnoreErrors: !tc.expectStart}).Leveled(slog.LevelDebug)
119+
enqueuer = notiffake.FakeNotificationEnqueuer{}
120+
client = coderdtest.New(t, &coderdtest.Options{
117121
AutobuildTicker: tickCh,
118122
IncludeProvisionerDaemon: true,
119123
AutobuildStats: statsCh,
120124
Logger: &logger,
125+
NotificationsEnqueuer: &enqueuer,
121126
})
122127
// Given: we have a user with a workspace that has autostart enabled
123128
workspace = mustProvisionWorkspace(t, client, func(cwr *codersdk.CreateWorkspaceRequest) {
@@ -195,6 +200,20 @@ func TestExecutorAutostartTemplateUpdated(t *testing.T) {
195200
assert.Equal(t, workspace.LatestBuild.TemplateVersionID, ws.LatestBuild.TemplateVersionID,
196201
"expected workspace build to be using the old template version")
197202
}
203+
204+
if tc.expectNotification {
205+
require.Len(t, enqueuer.Sent, 1)
206+
require.Equal(t, enqueuer.Sent[0].UserID, workspace.OwnerID)
207+
require.Contains(t, enqueuer.Sent[0].Targets, workspace.TemplateID)
208+
require.Contains(t, enqueuer.Sent[0].Targets, workspace.ID)
209+
require.Contains(t, enqueuer.Sent[0].Targets, workspace.OrganizationID)
210+
require.Contains(t, enqueuer.Sent[0].Targets, workspace.OwnerID)
211+
require.Equal(t, newVersion.Name, enqueuer.Sent[0].Labels["template_version_name"])
212+
require.Equal(t, "autobuild", enqueuer.Sent[0].Labels["initiator"])
213+
require.Equal(t, "autostart", enqueuer.Sent[0].Labels["reason"])
214+
} else {
215+
require.Len(t, enqueuer.Sent, 0)
216+
}
198217
})
199218
}
200219
}

coderd/coderdtest/coderdtest.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,8 @@ import (
6464
"github.com/coder/coder/v2/coderd/externalauth"
6565
"github.com/coder/coder/v2/coderd/gitsshkey"
6666
"github.com/coder/coder/v2/coderd/httpmw"
67+
"github.com/coder/coder/v2/coderd/notifications"
68+
"github.com/coder/coder/v2/coderd/notifications/notiffake"
6769
"github.com/coder/coder/v2/coderd/rbac"
6870
"github.com/coder/coder/v2/coderd/schedule"
6971
"github.com/coder/coder/v2/coderd/telemetry"
@@ -154,6 +156,8 @@ type Options struct {
154156
DatabaseRolluper *dbrollup.Rolluper
155157
WorkspaceUsageTrackerFlush chan int
156158
WorkspaceUsageTrackerTick chan time.Time
159+
160+
NotificationsEnqueuer notifications.Enqueuer
157161
}
158162

159163
// New constructs a codersdk client connected to an in-memory API instance.
@@ -238,6 +242,10 @@ func NewOptions(t testing.TB, options *Options) (func(http.Handler), context.Can
238242
options.Database, options.Pubsub = dbtestutil.NewDB(t)
239243
}
240244

245+
if options.NotificationsEnqueuer == nil {
246+
options.NotificationsEnqueuer = new(notiffake.FakeNotificationEnqueuer)
247+
}
248+
241249
accessControlStore := &atomic.Pointer[dbauthz.AccessControlStore]{}
242250
var acs dbauthz.AccessControlStore = dbauthz.AGPLTemplateAccessControlStore{}
243251
accessControlStore.Store(&acs)
@@ -305,6 +313,7 @@ func NewOptions(t testing.TB, options *Options) (func(http.Handler), context.Can
305313
accessControlStore,
306314
*options.Logger,
307315
options.AutobuildTicker,
316+
options.NotificationsEnqueuer,
308317
).WithStatsChannel(options.AutobuildStats)
309318
lifecycleExecutor.Run()
310319

@@ -498,6 +507,7 @@ func NewOptions(t testing.TB, options *Options) (func(http.Handler), context.Can
498507
NewTicker: options.NewTicker,
499508
DatabaseRolluper: options.DatabaseRolluper,
500509
WorkspaceUsageTracker: wuTracker,
510+
NotificationsEnqueuer: options.NotificationsEnqueuer,
501511
}
502512
}
503513

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
DELETE FROM notification_templates WHERE id = 'c34a0c09-0704-4cac-bd1c-0c0146811c2b';
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
INSERT INTO notification_templates (id, name, title_template, body_template, "group", actions)
2+
VALUES ('c34a0c09-0704-4cac-bd1c-0c0146811c2b', 'Workspace updated automatically', E'Workspace "{{.Labels.name}}" updated automatically',
3+
E'Hi {{.UserName}}\n\Your workspace **{{.Labels.name}}** has been updated automatically to the latest template version ({{.Labels.template_version_name}}).',
4+
'Workspace Events', '[
5+
{
6+
"label": "View workspace",
7+
"url": "{{ base_url }}/@{{.UserName}}/{{.Labels.name}}"
8+
}
9+
]'::jsonb);

coderd/notifications/events.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,4 +9,5 @@ import "github.com/google/uuid"
99
var (
1010
TemplateWorkspaceDeleted = uuid.MustParse("f517da0b-cdc9-410f-ab89-a86107c420ed")
1111
WorkspaceAutobuildFailed = uuid.MustParse("381df2a9-c0c0-4749-420f-80a9280c66f9")
12+
WorkspaceAutoUpdated = uuid.MustParse("c34a0c09-0704-4cac-bd1c-0c0146811c2b")
1213
)
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
package notiffake
2+
3+
import (
4+
"context"
5+
"sync"
6+
7+
"github.com/google/uuid"
8+
)
9+
10+
type FakeNotificationEnqueuer struct {
11+
mu sync.Mutex
12+
13+
Sent []*Notification
14+
}
15+
16+
type Notification struct {
17+
UserID, TemplateID uuid.UUID
18+
Labels map[string]string
19+
CreatedBy string
20+
Targets []uuid.UUID
21+
}
22+
23+
func (f *FakeNotificationEnqueuer) Enqueue(_ context.Context, userID, templateID uuid.UUID, labels map[string]string, createdBy string, targets ...uuid.UUID) (*uuid.UUID, error) {
24+
f.mu.Lock()
25+
defer f.mu.Unlock()
26+
27+
f.Sent = append(f.Sent, &Notification{
28+
UserID: userID,
29+
TemplateID: templateID,
30+
Labels: labels,
31+
CreatedBy: createdBy,
32+
Targets: targets,
33+
})
34+
35+
id := uuid.New()
36+
return &id, nil
37+
}

0 commit comments

Comments
 (0)