mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: notify on successful autoupdate (#13903)
This commit is contained in:
+1
-1
@@ -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())
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
DELETE FROM notification_templates WHERE id = 'c34a0c09-0704-4cac-bd1c-0c0146811c2b';
|
||||
@@ -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);
|
||||
@@ -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")
|
||||
)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user