Skip to content

feat: notify on successful autoupdate #13903

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 10 commits into from
Jul 18, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion cli/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down
48 changes: 45 additions & 3 deletions coderd/autobuild/lifecycle_executor.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand All @@ -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.
Expand All @@ -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),
Expand All @@ -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
}
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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)
Expand All @@ -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)
}
Expand Down Expand Up @@ -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)
}
Expand Down
33 changes: 26 additions & 7 deletions coderd/autobuild/lifecycle_executor_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -79,6 +80,7 @@ func TestExecutorAutostartTemplateUpdated(t *testing.T) {
compatibleParameters bool
expectStart bool
expectUpdate bool
expectNotification bool
}{
{
name: "Never",
Expand All @@ -93,6 +95,7 @@ func TestExecutorAutostartTemplateUpdated(t *testing.T) {
compatibleParameters: true,
expectStart: true,
expectUpdate: true,
expectNotification: true,
},
{
name: "Always_Incompatible",
Expand All @@ -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) {
Expand Down Expand Up @@ -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)
}
})
}
}
Expand Down
10 changes: 10 additions & 0 deletions coderd/coderdtest/coderdtest.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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()

Expand Down Expand Up @@ -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,
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
DELETE FROM notification_templates WHERE id = 'c34a0c09-0704-4cac-bd1c-0c0146811c2b';
Original file line number Diff line number Diff line change
@@ -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);
1 change: 1 addition & 0 deletions coderd/notifications/events.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
)
37 changes: 37 additions & 0 deletions coderd/notifications/notiffake/notiffake.go
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the other PR, we did this a bit differently. We added this fake helper into the testutils package. Link to PR

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, I'm aware of it. This is an alternative approach, and I'm happy to leave it or switch to the other form. @dannykopping any preference?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I prefer names which are explicit and clear, so I prefer testutils TBH.

Copy link
Member Author

@mtojek mtojek Jul 18, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I will wait with merging this PR until Bruno merges #13868, then adjust it.

Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package notiffake
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: I like splitting this out but I think the name is too "cute".
I think something like testutil or something would be more idiomatic.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Alright, I decided to merge it as is, and refactor/unify once #13868 is merged.


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
}
Loading