feat: implement thin vertical slice of system-generated notifications (#13537)

This commit is contained in:
Danny Kopping
2024-07-08 15:38:50 +02:00
committed by GitHub
parent 10aa32ca08
commit bdd2caf95d
51 changed files with 3686 additions and 50 deletions
@@ -25,6 +25,7 @@ import (
protobuf "google.golang.org/protobuf/proto"
"cdr.dev/slog"
"github.com/coder/coder/v2/coderd/apikey"
"github.com/coder/coder/v2/coderd/audit"
"github.com/coder/coder/v2/coderd/database"
@@ -32,6 +33,7 @@ import (
"github.com/coder/coder/v2/coderd/database/dbtime"
"github.com/coder/coder/v2/coderd/database/pubsub"
"github.com/coder/coder/v2/coderd/externalauth"
"github.com/coder/coder/v2/coderd/notifications"
"github.com/coder/coder/v2/coderd/promoauth"
"github.com/coder/coder/v2/coderd/schedule"
"github.com/coder/coder/v2/coderd/telemetry"
@@ -96,6 +98,7 @@ type server struct {
TemplateScheduleStore *atomic.Pointer[schedule.TemplateScheduleStore]
UserQuietHoursScheduleStore *atomic.Pointer[schedule.UserQuietHoursScheduleStore]
DeploymentValues *codersdk.DeploymentValues
NotificationEnqueuer notifications.Enqueuer
OIDCConfig promoauth.OAuth2Config
@@ -150,6 +153,7 @@ func NewServer(
userQuietHoursScheduleStore *atomic.Pointer[schedule.UserQuietHoursScheduleStore],
deploymentValues *codersdk.DeploymentValues,
options Options,
enqueuer notifications.Enqueuer,
) (proto.DRPCProvisionerDaemonServer, error) {
// Fail-fast if pointers are nil
if lifecycleCtx == nil {
@@ -198,6 +202,7 @@ func NewServer(
Database: db,
Pubsub: ps,
Acquirer: acquirer,
NotificationEnqueuer: enqueuer,
Telemetry: tel,
Tracer: tracer,
QuotaCommitter: quotaCommitter,
@@ -1411,6 +1416,11 @@ func (s *server) CompleteJob(ctx context.Context, completed *proto.CompletedJob)
// audit the outcome of the workspace build
if getWorkspaceError == nil {
// If the workspace has been deleted, notify the owner about it.
if workspaceBuild.Transition == database.WorkspaceTransitionDelete {
s.notifyWorkspaceDeleted(ctx, workspace, workspaceBuild)
}
auditor := s.Auditor.Load()
auditAction := auditActionFromTransition(workspaceBuild.Transition)
@@ -1511,6 +1521,41 @@ func (s *server) CompleteJob(ctx context.Context, completed *proto.CompletedJob)
return &proto.Empty{}, nil
}
func (s *server) notifyWorkspaceDeleted(ctx context.Context, workspace database.Workspace, build database.WorkspaceBuild) {
var reason string
if build.Reason.Valid() {
switch build.Reason {
case database.BuildReasonInitiator:
if build.InitiatorID == workspace.OwnerID {
// Deletions initiated by self should not notify.
return
}
reason = "initiated by user"
case database.BuildReasonAutodelete:
reason = "autodeleted due to dormancy"
default:
reason = string(build.Reason)
}
} else {
reason = string(build.Reason)
s.Logger.Warn(ctx, "invalid build reason when sending deletion notification",
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,
map[string]string{
"name": workspace.Name,
"initiatedBy": build.InitiatorByUsername,
"reason": reason,
}, "provisionerdserver",
// Associate this notification with all the related entities.
workspace.ID, workspace.OwnerID, workspace.TemplateID, workspace.OrganizationID,
); err != nil {
s.Logger.Warn(ctx, "failed to notify of workspace deletion", slog.Error(err))
}
}
func (s *server) startTrace(ctx context.Context, name string, opts ...trace.SpanStartOption) (context.Context, trace.Span) {
return s.Tracer.Start(ctx, name, append(opts, trace.WithAttributes(
semconv.ServiceNameKey.String("coderd.provisionerd"),
@@ -24,6 +24,8 @@ import (
"golang.org/x/oauth2"
"cdr.dev/slog/sloggers/slogtest"
"github.com/coder/serpent"
"github.com/coder/coder/v2/buildinfo"
"github.com/coder/coder/v2/coderd/audit"
"github.com/coder/coder/v2/coderd/database"
@@ -32,6 +34,7 @@ import (
"github.com/coder/coder/v2/coderd/database/dbtime"
"github.com/coder/coder/v2/coderd/database/pubsub"
"github.com/coder/coder/v2/coderd/externalauth"
"github.com/coder/coder/v2/coderd/notifications"
"github.com/coder/coder/v2/coderd/provisionerdserver"
"github.com/coder/coder/v2/coderd/schedule"
"github.com/coder/coder/v2/coderd/schedule/cron"
@@ -41,7 +44,6 @@ import (
"github.com/coder/coder/v2/provisionersdk"
sdkproto "github.com/coder/coder/v2/provisionersdk/proto"
"github.com/coder/coder/v2/testutil"
"github.com/coder/serpent"
)
func testTemplateScheduleStore() *atomic.Pointer[schedule.TemplateScheduleStore] {
@@ -1564,6 +1566,137 @@ func TestInsertWorkspaceResource(t *testing.T) {
})
}
func TestNotifications(t *testing.T) {
t.Parallel()
t.Run("Workspace deletion", func(t *testing.T) {
t.Parallel()
tests := []struct {
name string
deletionReason database.BuildReason
shouldNotify bool
shouldSelfInitiate bool
}{
{
name: "initiated by autodelete",
deletionReason: database.BuildReasonAutodelete,
shouldNotify: true,
},
{
name: "initiated by self",
deletionReason: database.BuildReasonInitiator,
shouldNotify: false,
shouldSelfInitiate: true,
},
{
name: "initiated by someone else",
deletionReason: database.BuildReasonInitiator,
shouldNotify: true,
shouldSelfInitiate: false,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
ctx := context.Background()
notifEnq := &fakeNotificationEnqueuer{}
srv, db, ps, pd := setup(t, false, &overrides{
notificationEnqueuer: notifEnq,
})
user := dbgen.User(t, db, database.User{})
initiator := user
if !tc.shouldSelfInitiate {
initiator = dbgen.User(t, db, database.User{})
}
template := dbgen.Template(t, db, database.Template{
Name: "template",
Provisioner: database.ProvisionerTypeEcho,
OrganizationID: pd.OrganizationID,
})
template, err := db.GetTemplateByID(ctx, template.ID)
require.NoError(t, err)
file := dbgen.File(t, db, database.File{CreatedBy: user.ID})
workspace := dbgen.Workspace(t, db, database.Workspace{
TemplateID: template.ID,
OwnerID: user.ID,
OrganizationID: pd.OrganizationID,
})
version := dbgen.TemplateVersion(t, db, database.TemplateVersion{
OrganizationID: pd.OrganizationID,
TemplateID: uuid.NullUUID{
UUID: template.ID,
Valid: true,
},
JobID: uuid.New(),
})
build := dbgen.WorkspaceBuild(t, db, database.WorkspaceBuild{
WorkspaceID: workspace.ID,
TemplateVersionID: version.ID,
InitiatorID: initiator.ID,
Transition: database.WorkspaceTransitionDelete,
Reason: tc.deletionReason,
})
job := dbgen.ProvisionerJob(t, db, ps, database.ProvisionerJob{
FileID: file.ID,
Type: database.ProvisionerJobTypeWorkspaceBuild,
Input: must(json.Marshal(provisionerdserver.WorkspaceProvisionJob{
WorkspaceBuildID: build.ID,
})),
OrganizationID: pd.OrganizationID,
})
_, err = db.AcquireProvisionerJob(ctx, database.AcquireProvisionerJobParams{
OrganizationID: pd.OrganizationID,
WorkerID: uuid.NullUUID{
UUID: pd.ID,
Valid: true,
},
Types: []database.ProvisionerType{database.ProvisionerTypeEcho},
})
require.NoError(t, err)
_, err = srv.CompleteJob(ctx, &proto.CompletedJob{
JobId: job.ID.String(),
Type: &proto.CompletedJob_WorkspaceBuild_{
WorkspaceBuild: &proto.CompletedJob_WorkspaceBuild{
State: []byte{},
Resources: []*sdkproto.Resource{{
Name: "example",
Type: "aws_instance",
}},
},
},
})
require.NoError(t, err)
workspace, err = db.GetWorkspaceByID(ctx, workspace.ID)
require.NoError(t, err)
require.True(t, workspace.Deleted)
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)
if tc.deletionReason == database.BuildReasonInitiator {
require.Equal(t, notifEnq.sent[0].labels["initiatedBy"], initiator.Username)
}
} else {
require.Len(t, notifEnq.sent, 0)
}
})
}
})
}
type overrides struct {
ctx context.Context
deploymentValues *codersdk.DeploymentValues
@@ -1575,6 +1708,7 @@ type overrides struct {
heartbeatFn func(ctx context.Context) error
heartbeatInterval time.Duration
auditor audit.Auditor
notificationEnqueuer notifications.Enqueuer
}
func setup(t *testing.T, ignoreLogErrors bool, ov *overrides) (proto.DRPCProvisionerDaemonServer, database.Store, pubsub.Pubsub, database.ProvisionerDaemon) {
@@ -1636,6 +1770,12 @@ func setup(t *testing.T, ignoreLogErrors bool, ov *overrides) (proto.DRPCProvisi
}
auditPtr.Store(&auditor)
pollDur = ov.acquireJobLongPollDuration
var notifEnq notifications.Enqueuer
if ov.notificationEnqueuer != nil {
notifEnq = ov.notificationEnqueuer
} else {
notifEnq = notifications.NewNoopEnqueuer()
}
daemon, err := db.UpsertProvisionerDaemon(ov.ctx, database.UpsertProvisionerDaemonParams{
Name: "test",
@@ -1675,6 +1815,7 @@ func setup(t *testing.T, ignoreLogErrors bool, ov *overrides) (proto.DRPCProvisi
HeartbeatInterval: ov.heartbeatInterval,
HeartbeatFn: ov.heartbeatFn,
},
notifEnq,
)
require.NoError(t, err)
return srv, db, ps, daemon
@@ -1778,3 +1919,31 @@ 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, &notification{
userID: userID,
templateID: templateID,
labels: labels,
createdBy: createdBy,
targets: targets,
})
id := uuid.New()
return &id, nil
}