diff --git a/cli/server.go b/cli/server.go index dc713c0cb765c..bb2678a041f5d 100644 --- a/cli/server.go +++ b/cli/server.go @@ -1066,7 +1066,7 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd. autobuildTicker := time.NewTicker(vals.AutobuildPollInterval.Value()) defer autobuildTicker.Stop() autobuildExecutor := autobuild.NewExecutor( - ctx, options.Database, options.Pubsub, coderAPI.TemplateScheduleStore, &coderAPI.Auditor, coderAPI.AccessControlStore, logger, autobuildTicker.C) + ctx, options.Database, options.Pubsub, coderAPI.TemplateScheduleStore, &coderAPI.Auditor, coderAPI.AccessControlStore, logger, autobuildTicker.C, options.NotificationsEnqueuer) autobuildExecutor.Run() hangDetectorTicker := time.NewTicker(vals.JobHangDetectorInterval.Value()) diff --git a/coderd/autobuild/lifecycle_executor.go b/coderd/autobuild/lifecycle_executor.go index 4bbbaba667c7e..775b5cb803714 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/notifications" "github.com/coder/coder/v2/coderd/schedule" "github.com/coder/coder/v2/coderd/wsbuilder" ) @@ -34,6 +35,9 @@ 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 } // Stats contains information about one run of Executor. @@ -44,7 +48,7 @@ type Stats struct { } // New returns a new wsactions executor. -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 { +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 { le := &Executor{ //nolint:gocritic // Autostart has a limited set of permissions. ctx: dbauthz.AsAutostart(ctx), @@ -55,6 +59,7 @@ func NewExecutor(ctx context.Context, db database.Store, ps pubsub.Pubsub, tss * log: log.Named("autobuild"), auditor: auditor, accessControlStore: acs, + notificationsEnqueuer: enqueuer, } return le } @@ -138,11 +143,18 @@ 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 err := e.db.InTx(func(tx database.Store) error { + var err error + // Re-check eligibility since the first check was outside the // transaction and the workspace settings may have changed. - ws, err := tx.GetWorkspaceByID(e.ctx, wsID) + ws, err = tx.GetWorkspaceByID(e.ctx, wsID) if err != nil { return xerrors.Errorf("get workspace by id: %w", err) } @@ -173,6 +185,11 @@ func (e *Executor) runOnce(t time.Time) Stats { return xerrors.Errorf("get template by ID: %w", err) } + activeTemplateVersion, err = tx.GetTemplateVersionByID(e.ctx, template.ActiveVersionID) + if err != nil { + return xerrors.Errorf("get active template version by ID: %w", err) + } + accessControl := (*(e.accessControlStore.Load())).GetTemplateAccessControl(template) nextTransition, reason, err := getNextTransition(user, ws, latestBuild, latestJob, templateSchedule, currentTick) @@ -195,9 +212,15 @@ func (e *Executor) runOnce(t time.Time) Stats { useActiveVersion(accessControl, ws) { log.Debug(e.ctx, "autostarting with active version") builder = builder.ActiveVersion() + + if latestBuild.TemplateVersionID != template.ActiveVersionID { + // control flag to know if the workspace was auto-updated, + // so the lifecycle executor can notify the user + didAutoUpdate = true + } } - _, job, err = builder.Build(e.ctx, tx, nil, audit.WorkspaceBuildBaggage{IP: "127.0.0.1"}) + nextBuild, job, err = builder.Build(e.ctx, tx, nil, audit.WorkspaceBuildBaggage{IP: "127.0.0.1"}) if err != nil { return xerrors.Errorf("build workspace with transition %q: %w", nextTransition, err) } @@ -261,6 +284,25 @@ func (e *Executor) runOnce(t time.Time) Stats { auditLog.Success = err == nil auditBuild(e.ctx, log, *e.auditor.Load(), *auditLog) } + if didAutoUpdate && err == nil { + nextBuildReason := "" + if nextBuild != nil { + nextBuildReason = string(nextBuild.Reason) + } + + if _, err := e.notificationsEnqueuer.Enqueue(e.ctx, ws.OwnerID, notifications.WorkspaceAutoUpdated, + map[string]string{ + "name": ws.Name, + "initiator": "autobuild", + "reason": nextBuildReason, + "template_version_name": activeTemplateVersion.Name, + }, "autobuild", + // Associate this notification with all the related entities. + ws.ID, ws.OwnerID, ws.TemplateID, ws.OrganizationID, + ); err != nil { + log.Warn(e.ctx, "failed to notify of autoupdated workspace", slog.Error(err)) + } + } if err != nil { return xerrors.Errorf("transition workspace: %w", err) } diff --git a/coderd/autobuild/lifecycle_executor_test.go b/coderd/autobuild/lifecycle_executor_test.go index bc480b97e4aa2..c5362ef13829d 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/notiffake" "github.com/coder/coder/v2/coderd/schedule" "github.com/coder/coder/v2/coderd/schedule/cron" "github.com/coder/coder/v2/coderd/util/ptr" @@ -79,6 +80,7 @@ func TestExecutorAutostartTemplateUpdated(t *testing.T) { compatibleParameters bool expectStart bool expectUpdate bool + expectNotification bool }{ { name: "Never", @@ -93,6 +95,7 @@ func TestExecutorAutostartTemplateUpdated(t *testing.T) { compatibleParameters: true, expectStart: true, expectUpdate: true, + expectNotification: true, }, { name: "Always_Incompatible", @@ -107,17 +110,19 @@ func TestExecutorAutostartTemplateUpdated(t *testing.T) { t.Run(tc.name, func(t *testing.T) { t.Parallel() var ( - sched = mustSchedule(t, "CRON_TZ=UTC 0 * * * *") - ctx = context.Background() - err error - tickCh = make(chan time.Time) - statsCh = make(chan autobuild.Stats) - logger = slogtest.Make(t, &slogtest.Options{IgnoreErrors: !tc.expectStart}).Leveled(slog.LevelDebug) - client = coderdtest.New(t, &coderdtest.Options{ + sched = mustSchedule(t, "CRON_TZ=UTC 0 * * * *") + ctx = context.Background() + err error + tickCh = make(chan time.Time) + statsCh = make(chan autobuild.Stats) + logger = slogtest.Make(t, &slogtest.Options{IgnoreErrors: !tc.expectStart}).Leveled(slog.LevelDebug) + enqueuer = notiffake.FakeNotificationEnqueuer{} + client = coderdtest.New(t, &coderdtest.Options{ AutobuildTicker: tickCh, IncludeProvisionerDaemon: true, AutobuildStats: statsCh, Logger: &logger, + NotificationsEnqueuer: &enqueuer, }) // Given: we have a user with a workspace that has autostart enabled workspace = mustProvisionWorkspace(t, client, func(cwr *codersdk.CreateWorkspaceRequest) { @@ -195,6 +200,20 @@ func TestExecutorAutostartTemplateUpdated(t *testing.T) { assert.Equal(t, workspace.LatestBuild.TemplateVersionID, ws.LatestBuild.TemplateVersionID, "expected workspace build to be using the old template version") } + + if tc.expectNotification { + require.Len(t, enqueuer.Sent, 1) + require.Equal(t, enqueuer.Sent[0].UserID, workspace.OwnerID) + require.Contains(t, enqueuer.Sent[0].Targets, workspace.TemplateID) + require.Contains(t, enqueuer.Sent[0].Targets, workspace.ID) + require.Contains(t, enqueuer.Sent[0].Targets, workspace.OrganizationID) + require.Contains(t, enqueuer.Sent[0].Targets, workspace.OwnerID) + require.Equal(t, newVersion.Name, enqueuer.Sent[0].Labels["template_version_name"]) + require.Equal(t, "autobuild", enqueuer.Sent[0].Labels["initiator"]) + require.Equal(t, "autostart", enqueuer.Sent[0].Labels["reason"]) + } else { + require.Len(t, enqueuer.Sent, 0) + } }) } } diff --git a/coderd/coderdtest/coderdtest.go b/coderd/coderdtest/coderdtest.go index 97541ea927c98..0d787b98e8f7e 100644 --- a/coderd/coderdtest/coderdtest.go +++ b/coderd/coderdtest/coderdtest.go @@ -64,6 +64,8 @@ import ( "github.com/coder/coder/v2/coderd/externalauth" "github.com/coder/coder/v2/coderd/gitsshkey" "github.com/coder/coder/v2/coderd/httpmw" + "github.com/coder/coder/v2/coderd/notifications" + "github.com/coder/coder/v2/coderd/notifications/notiffake" "github.com/coder/coder/v2/coderd/rbac" "github.com/coder/coder/v2/coderd/schedule" "github.com/coder/coder/v2/coderd/telemetry" @@ -154,6 +156,8 @@ type Options struct { DatabaseRolluper *dbrollup.Rolluper WorkspaceUsageTrackerFlush chan int WorkspaceUsageTrackerTick chan time.Time + + NotificationsEnqueuer notifications.Enqueuer } // 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 options.Database, options.Pubsub = dbtestutil.NewDB(t) } + if options.NotificationsEnqueuer == nil { + options.NotificationsEnqueuer = new(notiffake.FakeNotificationEnqueuer) + } + accessControlStore := &atomic.Pointer[dbauthz.AccessControlStore]{} var acs dbauthz.AccessControlStore = dbauthz.AGPLTemplateAccessControlStore{} accessControlStore.Store(&acs) @@ -305,6 +313,7 @@ func NewOptions(t testing.TB, options *Options) (func(http.Handler), context.Can accessControlStore, *options.Logger, options.AutobuildTicker, + options.NotificationsEnqueuer, ).WithStatsChannel(options.AutobuildStats) lifecycleExecutor.Run() @@ -498,6 +507,7 @@ func NewOptions(t testing.TB, options *Options) (func(http.Handler), context.Can NewTicker: options.NewTicker, DatabaseRolluper: options.DatabaseRolluper, WorkspaceUsageTracker: wuTracker, + NotificationsEnqueuer: options.NotificationsEnqueuer, } } diff --git a/coderd/database/migrations/000228_notifications_workspace_autoupdated.down.sql b/coderd/database/migrations/000228_notifications_workspace_autoupdated.down.sql new file mode 100644 index 0000000000000..cc3b21fc0cc11 --- /dev/null +++ b/coderd/database/migrations/000228_notifications_workspace_autoupdated.down.sql @@ -0,0 +1 @@ +DELETE FROM notification_templates WHERE id = 'c34a0c09-0704-4cac-bd1c-0c0146811c2b'; diff --git a/coderd/database/migrations/000228_notifications_workspace_autoupdated.up.sql b/coderd/database/migrations/000228_notifications_workspace_autoupdated.up.sql new file mode 100644 index 0000000000000..3f5d6db2d74a5 --- /dev/null +++ b/coderd/database/migrations/000228_notifications_workspace_autoupdated.up.sql @@ -0,0 +1,9 @@ +INSERT INTO notification_templates (id, name, title_template, body_template, "group", actions) +VALUES ('c34a0c09-0704-4cac-bd1c-0c0146811c2b', 'Workspace updated automatically', E'Workspace "{{.Labels.name}}" updated automatically', + E'Hi {{.UserName}}\n\Your workspace **{{.Labels.name}}** has been updated automatically to the latest template version ({{.Labels.template_version_name}}).', + 'Workspace Events', '[ + { + "label": "View workspace", + "url": "{{ base_url }}/@{{.UserName}}/{{.Labels.name}}" + } + ]'::jsonb); diff --git a/coderd/notifications/events.go b/coderd/notifications/events.go index 59ff87f67eef9..910c571cd6ab0 100644 --- a/coderd/notifications/events.go +++ b/coderd/notifications/events.go @@ -9,4 +9,5 @@ import "github.com/google/uuid" 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") ) diff --git a/coderd/notifications/notiffake/notiffake.go b/coderd/notifications/notiffake/notiffake.go new file mode 100644 index 0000000000000..2435d86162877 --- /dev/null +++ b/coderd/notifications/notiffake/notiffake.go @@ -0,0 +1,37 @@ +package notiffake + +import ( + "context" + "sync" + + "github.com/google/uuid" +) + +type FakeNotificationEnqueuer struct { + mu sync.Mutex + + Sent []*Notification +} + +type Notification struct { + UserID, TemplateID uuid.UUID + Labels map[string]string + CreatedBy string + Targets []uuid.UUID +} + +func (f *FakeNotificationEnqueuer) Enqueue(_ context.Context, userID, templateID uuid.UUID, labels map[string]string, createdBy string, targets ...uuid.UUID) (*uuid.UUID, error) { + f.mu.Lock() + defer f.mu.Unlock() + + f.Sent = append(f.Sent, &Notification{ + UserID: userID, + TemplateID: templateID, + Labels: labels, + CreatedBy: createdBy, + Targets: targets, + }) + + id := uuid.New() + return &id, nil +}