diff --git a/coderd/autobuild/lifecycle_executor.go b/coderd/autobuild/lifecycle_executor.go index 10692f91ff1c8..8da233d6d610f 100644 --- a/coderd/autobuild/lifecycle_executor.go +++ b/coderd/autobuild/lifecycle_executor.go @@ -13,6 +13,7 @@ import ( "golang.org/x/xerrors" "cdr.dev/slog" + "github.com/coder/coder/v2/coderd/audit" "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/dbauthz" @@ -296,10 +297,11 @@ func (e *Executor) runOnce(t time.Time) Stats { if _, err := e.notificationsEnqueuer.Enqueue(e.ctx, ws.OwnerID, notifications.TemplateWorkspaceAutoUpdated, map[string]string{ - "name": ws.Name, - "initiator": "autobuild", - "reason": nextBuildReason, - "template_version_name": activeTemplateVersion.Name, + "name": ws.Name, + "initiator": "autobuild", + "reason": nextBuildReason, + "template_version_name": activeTemplateVersion.Name, + "template_version_message": activeTemplateVersion.Message, }, "autobuild", // Associate this notification with all the related entities. ws.ID, ws.OwnerID, ws.TemplateID, ws.OrganizationID, diff --git a/coderd/database/migrations/000240_notification_workspace_updated_version_message.down.sql b/coderd/database/migrations/000240_notification_workspace_updated_version_message.down.sql new file mode 100644 index 0000000000000..92f26f300b501 --- /dev/null +++ b/coderd/database/migrations/000240_notification_workspace_updated_version_message.down.sql @@ -0,0 +1,4 @@ +UPDATE notification_templates +SET body_template = E'Hi {{.UserName}}\n' || + E'Your workspace **{{.Labels.name}}** has been updated automatically to the latest template version ({{.Labels.template_version_name}}).' +WHERE id = 'c34a0c09-0704-4cac-bd1c-0c0146811c2b'; \ No newline at end of file diff --git a/coderd/database/migrations/000240_notification_workspace_updated_version_message.up.sql b/coderd/database/migrations/000240_notification_workspace_updated_version_message.up.sql new file mode 100644 index 0000000000000..9eb769cfb0817 --- /dev/null +++ b/coderd/database/migrations/000240_notification_workspace_updated_version_message.up.sql @@ -0,0 +1,6 @@ +UPDATE notification_templates +SET name = 'Workspace Updated Automatically', -- drive-by fix for capitalization to match other templates + body_template = E'Hi {{.UserName}}\n' || + E'Your workspace **{{.Labels.name}}** has been updated automatically to the latest template version ({{.Labels.template_version_name}}).\n' || + E'Reason for update: **{{.Labels.template_version_message}}**' -- include template version message +WHERE id = 'c34a0c09-0704-4cac-bd1c-0c0146811c2b'; \ No newline at end of file diff --git a/coderd/notifications/events.go b/coderd/notifications/events.go index c00912d70734c..94260317e20c9 100644 --- a/coderd/notifications/events.go +++ b/coderd/notifications/events.go @@ -3,7 +3,7 @@ package notifications import "github.com/google/uuid" // These vars are mapped to UUIDs in the notification_templates table. -// TODO: autogenerate these. +// TODO: autogenerate these: https://github.com/coder/team-coconut/issues/36 // Workspace-related events. var ( diff --git a/coderd/notifications/notifications_test.go b/coderd/notifications/notifications_test.go index 13ea5b65b96cc..baa7174b1e08d 100644 --- a/coderd/notifications/notifications_test.go +++ b/coderd/notifications/notifications_test.go @@ -1,9 +1,14 @@ package notifications_test import ( + "bytes" "context" + _ "embed" "encoding/json" "fmt" + "go/ast" + "go/parser" + "go/token" "net/http" "net/http/httptest" "net/url" @@ -606,7 +611,42 @@ func TestNotifierPaused(t *testing.T) { }, testutil.WaitShort, testutil.IntervalFast) } -func TestNotificationTemplatesBody(t *testing.T) { +//go:embed events.go +var events []byte + +// enumerateAllTemplates gets all the template names from the coderd/notifications/events.go file. +// TODO(dannyk): use code-generation to create a list of all templates: https://github.com/coder/team-coconut/issues/36 +func enumerateAllTemplates(t *testing.T) ([]string, error) { + t.Helper() + + fset := token.NewFileSet() + + node, err := parser.ParseFile(fset, "", bytes.NewBuffer(events), parser.AllErrors) + if err != nil { + return nil, err + } + + var out []string + // Traverse the AST and extract variable names. + ast.Inspect(node, func(n ast.Node) bool { + // Check if the node is a declaration statement. + if decl, ok := n.(*ast.GenDecl); ok && decl.Tok == token.VAR { + for _, spec := range decl.Specs { + // Type assert the spec to a ValueSpec. + if valueSpec, ok := spec.(*ast.ValueSpec); ok { + for _, name := range valueSpec.Names { + out = append(out, name.String()) + } + } + } + } + return true + }) + + return out, nil +} + +func TestNotificationTemplatesCanRender(t *testing.T) { t.Parallel() if !dbtestutil.WillUsePostgres() { @@ -647,10 +687,11 @@ func TestNotificationTemplatesBody(t *testing.T) { payload: types.MessagePayload{ UserName: "bobby", Labels: map[string]string{ - "name": "bobby-workspace", - "reason": "breached the template's threshold for inactivity", - "initiator": "autobuild", - "dormancyHours": "24", + "name": "bobby-workspace", + "reason": "breached the template's threshold for inactivity", + "initiator": "autobuild", + "dormancyHours": "24", + "timeTilDormant": "24h", }, }, }, @@ -660,8 +701,9 @@ func TestNotificationTemplatesBody(t *testing.T) { payload: types.MessagePayload{ UserName: "bobby", Labels: map[string]string{ - "name": "bobby-workspace", - "template_version_name": "1.0", + "name": "bobby-workspace", + "template_version_name": "1.0", + "template_version_message": "template now includes catnip", }, }, }, @@ -671,12 +713,46 @@ func TestNotificationTemplatesBody(t *testing.T) { payload: types.MessagePayload{ UserName: "bobby", Labels: map[string]string{ - "name": "bobby-workspace", - "reason": "template updated to new dormancy policy", - "dormancyHours": "24", + "name": "bobby-workspace", + "reason": "template updated to new dormancy policy", + "dormancyHours": "24", + "timeTilDormant": "24h", + }, + }, + }, + { + name: "TemplateUserAccountCreated", + id: notifications.TemplateUserAccountCreated, + payload: types.MessagePayload{ + UserName: "bobby", + Labels: map[string]string{ + "created_account_name": "bobby", }, }, }, + { + name: "TemplateUserAccountDeleted", + id: notifications.TemplateUserAccountDeleted, + payload: types.MessagePayload{ + UserName: "bobby", + Labels: map[string]string{ + "deleted_account_name": "bobby", + }, + }, + }, + } + + allTemplates, err := enumerateAllTemplates(t) + require.NoError(t, err) + for _, name := range allTemplates { + var found bool + for _, tc := range tests { + if tc.name == name { + found = true + } + } + + require.Truef(t, found, "could not find test case for %q", name) } for _, tc := range tests { @@ -697,6 +773,7 @@ func TestNotificationTemplatesBody(t *testing.T) { require.NoError(t, err, "failed to query body template for template:", tc.id) title, err := render.GoTemplate(titleTmpl, tc.payload, nil) + require.NotContainsf(t, title, render.NoValue, "template %q is missing a label value", tc.name) require.NoError(t, err, "failed to render notification title template") require.NotEmpty(t, title, "title should not be empty") diff --git a/coderd/notifications/render/gotmpl.go b/coderd/notifications/render/gotmpl.go index e194c9837d2a9..0bbb9f0c38b48 100644 --- a/coderd/notifications/render/gotmpl.go +++ b/coderd/notifications/render/gotmpl.go @@ -9,10 +9,19 @@ import ( "github.com/coder/coder/v2/coderd/notifications/types" ) +// NoValue is used when a template variable is not found. +// This string is not exported as a const from the text/template. +const NoValue = "" + // GoTemplate attempts to substitute the given payload into the given template using Go's templating syntax. // TODO: memoize templates for memory efficiency? func GoTemplate(in string, payload types.MessagePayload, extraFuncs template.FuncMap) (string, error) { - tmpl, err := template.New("text").Funcs(extraFuncs).Parse(in) + tmpl, err := template.New("text"). + Funcs(extraFuncs). + // text/template substitutes a missing label with "". + // NOTE: html/template does not, for obvious reasons. + Option("missingkey=invalid"). + Parse(in) if err != nil { return "", xerrors.Errorf("template parse: %w", err) }