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
+3 -3
View File
@@ -16,8 +16,8 @@ import (
"tailscale.com/tailcfg"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/parameter"
"github.com/coder/coder/v2/coderd/rbac"
"github.com/coder/coder/v2/coderd/render"
"github.com/coder/coder/v2/coderd/workspaceapps/appurl"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/provisionersdk/proto"
@@ -106,7 +106,7 @@ func TemplateVersionParameter(param database.TemplateVersionParameter) (codersdk
return codersdk.TemplateVersionParameter{}, err
}
descriptionPlaintext, err := parameter.Plaintext(param.Description)
descriptionPlaintext, err := render.PlaintextFromMarkdown(param.Description)
if err != nil {
return codersdk.TemplateVersionParameter{}, err
}
@@ -244,7 +244,7 @@ func TemplateInsightsParameters(parameterRows []database.GetTemplateParameterIns
return nil, err
}
plaintextDescription, err := parameter.Plaintext(param.Description)
plaintextDescription, err := render.PlaintextFromMarkdown(param.Description)
if err != nil {
return nil, err
}
+8
View File
@@ -17,6 +17,7 @@ import (
"github.com/open-policy-agent/opa/topdown"
"cdr.dev/slog"
"github.com/coder/coder/v2/coderd/rbac/policy"
"github.com/coder/coder/v2/coderd/rbac/rolestore"
@@ -1471,6 +1472,13 @@ func (q *querier) GetLogoURL(ctx context.Context) (string, error) {
return q.db.GetLogoURL(ctx)
}
func (q *querier) GetNotificationMessagesByStatus(ctx context.Context, arg database.GetNotificationMessagesByStatusParams) ([]database.NotificationMessage, error) {
if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceSystem); err != nil {
return nil, err
}
return q.db.GetNotificationMessagesByStatus(ctx, arg)
}
func (q *querier) GetOAuth2ProviderAppByID(ctx context.Context, id uuid.UUID) (database.OAuth2ProviderApp, error) {
if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceOauth2App); err != nil {
return database.OAuth2ProviderApp{}, err
+10 -1
View File
@@ -13,6 +13,7 @@ import (
"golang.org/x/xerrors"
"cdr.dev/slog"
"github.com/coder/coder/v2/coderd/database/db2sdk"
"github.com/coder/coder/v2/coderd/rbac/policy"
"github.com/coder/coder/v2/codersdk"
@@ -2486,13 +2487,21 @@ func (s *MethodTestSuite) TestSystemFunctions() {
s.Run("EnqueueNotificationMessage", s.Subtest(func(db database.Store, check *expects) {
// TODO: update this test once we have a specific role for notifications
check.Args(database.EnqueueNotificationMessageParams{
Method: database.NotificationMethodWebhook,
Method: database.NotificationMethodWebhook,
Payload: []byte("{}"),
}).Asserts(rbac.ResourceSystem, policy.ActionCreate)
}))
s.Run("FetchNewMessageMetadata", s.Subtest(func(db database.Store, check *expects) {
// TODO: update this test once we have a specific role for notifications
check.Args(database.FetchNewMessageMetadataParams{}).Asserts(rbac.ResourceSystem, policy.ActionRead)
}))
s.Run("GetNotificationMessagesByStatus", s.Subtest(func(db database.Store, check *expects) {
// TODO: update this test once we have a specific role for notifications
check.Args(database.GetNotificationMessagesByStatusParams{
Status: database.NotificationMessageStatusLeased,
Limit: 10,
}).Asserts(rbac.ResourceSystem, policy.ActionRead)
}))
}
func (s *MethodTestSuite) TestOAuth2ProviderApps() {
+99 -6
View File
@@ -21,6 +21,8 @@ import (
"golang.org/x/exp/slices"
"golang.org/x/xerrors"
"github.com/coder/coder/v2/coderd/notifications/types"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbtime"
"github.com/coder/coder/v2/coderd/rbac"
@@ -62,6 +64,7 @@ func New() database.Store {
auditLogs: make([]database.AuditLog, 0),
files: make([]database.File, 0),
gitSSHKey: make([]database.GitSSHKey, 0),
notificationMessages: make([]database.NotificationMessage, 0),
parameterSchemas: make([]database.ParameterSchema, 0),
provisionerDaemons: make([]database.ProvisionerDaemon, 0),
workspaceAgents: make([]database.WorkspaceAgent, 0),
@@ -156,6 +159,7 @@ type data struct {
groups []database.Group
jfrogXRayScans []database.JfrogXrayScan
licenses []database.License
notificationMessages []database.NotificationMessage
oauth2ProviderApps []database.OAuth2ProviderApp
oauth2ProviderAppSecrets []database.OAuth2ProviderAppSecret
oauth2ProviderAppCodes []database.OAuth2ProviderAppCode
@@ -917,13 +921,45 @@ func (*FakeQuerier) AcquireLock(_ context.Context, _ int64) error {
return xerrors.New("AcquireLock must only be called within a transaction")
}
func (*FakeQuerier) AcquireNotificationMessages(_ context.Context, arg database.AcquireNotificationMessagesParams) ([]database.AcquireNotificationMessagesRow, error) {
// AcquireNotificationMessages implements the *basic* business logic, but is *not* exhaustive or meant to be 1:1 with
// the real AcquireNotificationMessages query.
func (q *FakeQuerier) AcquireNotificationMessages(_ context.Context, arg database.AcquireNotificationMessagesParams) ([]database.AcquireNotificationMessagesRow, error) {
err := validateDatabaseType(arg)
if err != nil {
return nil, err
}
// nolint:nilnil // Irrelevant.
return nil, nil
q.mutex.Lock()
defer q.mutex.Unlock()
var out []database.AcquireNotificationMessagesRow
for _, nm := range q.notificationMessages {
if len(out) >= int(arg.Count) {
break
}
acquirableStatuses := []database.NotificationMessageStatus{database.NotificationMessageStatusPending, database.NotificationMessageStatusTemporaryFailure}
if !slices.Contains(acquirableStatuses, nm.Status) {
continue
}
// Mimic mutation in database query.
nm.UpdatedAt = sql.NullTime{Time: dbtime.Now(), Valid: true}
nm.Status = database.NotificationMessageStatusLeased
nm.StatusReason = sql.NullString{String: fmt.Sprintf("Enqueued by notifier %d", arg.NotifierID), Valid: true}
nm.LeasedUntil = sql.NullTime{Time: dbtime.Now().Add(time.Second * time.Duration(arg.LeaseSeconds)), Valid: true}
out = append(out, database.AcquireNotificationMessagesRow{
ID: nm.ID,
Payload: nm.Payload,
Method: nm.Method,
CreatedBy: nm.CreatedBy,
TitleTemplate: "This is a title with {{.Labels.variable}}",
BodyTemplate: "This is a body with {{.Labels.variable}}",
})
}
return out, nil
}
func (q *FakeQuerier) AcquireProvisionerJob(_ context.Context, arg database.AcquireProvisionerJobParams) (database.ProvisionerJob, error) {
@@ -1776,12 +1812,37 @@ func (q *FakeQuerier) DeleteWorkspaceAgentPortSharesByTemplate(_ context.Context
return nil
}
func (*FakeQuerier) EnqueueNotificationMessage(_ context.Context, arg database.EnqueueNotificationMessageParams) (database.NotificationMessage, error) {
func (q *FakeQuerier) EnqueueNotificationMessage(_ context.Context, arg database.EnqueueNotificationMessageParams) (database.NotificationMessage, error) {
err := validateDatabaseType(arg)
if err != nil {
return database.NotificationMessage{}, err
}
return database.NotificationMessage{}, nil
q.mutex.Lock()
defer q.mutex.Unlock()
var payload types.MessagePayload
err = json.Unmarshal(arg.Payload, &payload)
if err != nil {
return database.NotificationMessage{}, err
}
nm := database.NotificationMessage{
ID: arg.ID,
UserID: arg.UserID,
Method: arg.Method,
Payload: arg.Payload,
NotificationTemplateID: arg.NotificationTemplateID,
Targets: arg.Targets,
CreatedBy: arg.CreatedBy,
// Default fields.
CreatedAt: dbtime.Now(),
Status: database.NotificationMessageStatusPending,
}
q.notificationMessages = append(q.notificationMessages, nm)
return nm, err
}
func (q *FakeQuerier) FavoriteWorkspace(_ context.Context, arg uuid.UUID) error {
@@ -1808,7 +1869,19 @@ func (*FakeQuerier) FetchNewMessageMetadata(_ context.Context, arg database.Fetc
if err != nil {
return database.FetchNewMessageMetadataRow{}, err
}
return database.FetchNewMessageMetadataRow{}, nil
actions, err := json.Marshal([]types.TemplateAction{{URL: "http://xyz.com", Label: "XYZ"}})
if err != nil {
return database.FetchNewMessageMetadataRow{}, err
}
return database.FetchNewMessageMetadataRow{
UserEmail: "test@test.com",
UserName: "Testy McTester",
NotificationName: "Some notification",
Actions: actions,
UserID: arg.UserID,
}, nil
}
func (q *FakeQuerier) GetAPIKeyByID(_ context.Context, id string) (database.APIKey, error) {
@@ -2667,6 +2740,26 @@ func (q *FakeQuerier) GetLogoURL(_ context.Context) (string, error) {
return q.logoURL, nil
}
func (q *FakeQuerier) GetNotificationMessagesByStatus(_ context.Context, arg database.GetNotificationMessagesByStatusParams) ([]database.NotificationMessage, error) {
err := validateDatabaseType(arg)
if err != nil {
return nil, err
}
var out []database.NotificationMessage
for _, m := range q.notificationMessages {
if len(out) > int(arg.Limit) {
return out, nil
}
if m.Status == arg.Status {
out = append(out, m)
}
}
return out, nil
}
func (q *FakeQuerier) GetOAuth2ProviderAppByID(_ context.Context, id uuid.UUID) (database.OAuth2ProviderApp, error) {
q.mutex.Lock()
defer q.mutex.Unlock()
+7
View File
@@ -732,6 +732,13 @@ func (m metricsStore) GetLogoURL(ctx context.Context) (string, error) {
return url, err
}
func (m metricsStore) GetNotificationMessagesByStatus(ctx context.Context, arg database.GetNotificationMessagesByStatusParams) ([]database.NotificationMessage, error) {
start := time.Now()
r0, r1 := m.s.GetNotificationMessagesByStatus(ctx, arg)
m.queryLatencies.WithLabelValues("GetNotificationMessagesByStatus").Observe(time.Since(start).Seconds())
return r0, r1
}
func (m metricsStore) GetOAuth2ProviderAppByID(ctx context.Context, id uuid.UUID) (database.OAuth2ProviderApp, error) {
start := time.Now()
r0, r1 := m.s.GetOAuth2ProviderAppByID(ctx, id)
+15
View File
@@ -1452,6 +1452,21 @@ func (mr *MockStoreMockRecorder) GetLogoURL(arg0 any) *gomock.Call {
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetLogoURL", reflect.TypeOf((*MockStore)(nil).GetLogoURL), arg0)
}
// GetNotificationMessagesByStatus mocks base method.
func (m *MockStore) GetNotificationMessagesByStatus(arg0 context.Context, arg1 database.GetNotificationMessagesByStatusParams) ([]database.NotificationMessage, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetNotificationMessagesByStatus", arg0, arg1)
ret0, _ := ret[0].([]database.NotificationMessage)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetNotificationMessagesByStatus indicates an expected call of GetNotificationMessagesByStatus.
func (mr *MockStoreMockRecorder) GetNotificationMessagesByStatus(arg0, arg1 any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetNotificationMessagesByStatus", reflect.TypeOf((*MockStore)(nil).GetNotificationMessagesByStatus), arg0, arg1)
}
// GetOAuth2ProviderAppByID mocks base method.
func (m *MockStore) GetOAuth2ProviderAppByID(arg0 context.Context, arg1 uuid.UUID) (database.OAuth2ProviderApp, error) {
m.ctrl.T.Helper()
@@ -52,7 +52,7 @@ CREATE INDEX idx_notification_messages_status ON notification_messages (status);
-- TODO: autogenerate constants which reference the UUIDs
INSERT INTO notification_templates (id, name, title_template, body_template, "group", actions)
VALUES ('f517da0b-cdc9-410f-ab89-a86107c420ed', 'Workspace Deleted', E'Workspace "{{.Labels.name}}" deleted',
E'Hi {{.UserName}}\n\nYour workspace **{{.Labels.name}}** was deleted.\nThe specified reason was "**{{.Labels.reason}}**".',
E'Hi {{.UserName}}\n\nYour workspace **{{.Labels.name}}** was deleted.\nThe specified reason was "**{{.Labels.reason}}{{ if .Labels.initiator }} ({{ .Labels.initiator }}){{end}}**".',
'Workspace Events', '[
{
"label": "View workspaces",
+1
View File
@@ -160,6 +160,7 @@ type sqlcQuerier interface {
GetLicenseByID(ctx context.Context, id int32) (License, error)
GetLicenses(ctx context.Context) ([]License, error)
GetLogoURL(ctx context.Context) (string, error)
GetNotificationMessagesByStatus(ctx context.Context, arg GetNotificationMessagesByStatusParams) ([]NotificationMessage, error)
GetOAuth2ProviderAppByID(ctx context.Context, id uuid.UUID) (OAuth2ProviderApp, error)
GetOAuth2ProviderAppCodeByID(ctx context.Context, id uuid.UUID) (OAuth2ProviderAppCode, error)
GetOAuth2ProviderAppCodeByPrefix(ctx context.Context, secretPrefix []byte) (OAuth2ProviderAppCode, error)
+47
View File
@@ -3579,6 +3579,53 @@ func (q *sqlQuerier) FetchNewMessageMetadata(ctx context.Context, arg FetchNewMe
return i, err
}
const getNotificationMessagesByStatus = `-- name: GetNotificationMessagesByStatus :many
SELECT id, notification_template_id, user_id, method, status, status_reason, created_by, payload, attempt_count, targets, created_at, updated_at, leased_until, next_retry_after FROM notification_messages WHERE status = $1 LIMIT $2::int
`
type GetNotificationMessagesByStatusParams struct {
Status NotificationMessageStatus `db:"status" json:"status"`
Limit int32 `db:"limit" json:"limit"`
}
func (q *sqlQuerier) GetNotificationMessagesByStatus(ctx context.Context, arg GetNotificationMessagesByStatusParams) ([]NotificationMessage, error) {
rows, err := q.db.QueryContext(ctx, getNotificationMessagesByStatus, arg.Status, arg.Limit)
if err != nil {
return nil, err
}
defer rows.Close()
var items []NotificationMessage
for rows.Next() {
var i NotificationMessage
if err := rows.Scan(
&i.ID,
&i.NotificationTemplateID,
&i.UserID,
&i.Method,
&i.Status,
&i.StatusReason,
&i.CreatedBy,
&i.Payload,
&i.AttemptCount,
pq.Array(&i.Targets),
&i.CreatedAt,
&i.UpdatedAt,
&i.LeasedUntil,
&i.NextRetryAfter,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const deleteOAuth2ProviderAppByID = `-- name: DeleteOAuth2ProviderAppByID :exec
DELETE FROM oauth2_provider_apps WHERE id = $1
`
@@ -125,3 +125,6 @@ WHERE id IN
FROM notification_messages AS nested
WHERE nested.updated_at < NOW() - INTERVAL '7 days');
-- name: GetNotificationMessagesByStatus :many
SELECT * FROM notification_messages WHERE status = @status LIMIT sqlc.arg('limit')::int;