feat: notify on workspace update (#15979)

Relates to https://github.com/coder/coder/issues/15845

When the `/workspace/<name>/builds` endpoint is hit, we check if the
requested template version is different to the previously used template
version. If these values differ, we can assume that the workspace has
been manually updated and send the appropriate notification. Automatic
updates happen in the lifecycle executor and bypasses this endpoint
entirely.
This commit is contained in:
Danielle Maywood
2025-01-02 12:19:34 +00:00
committed by GitHub
parent 445392bdd8
commit f3fe3bc785
10 changed files with 397 additions and 28 deletions

View File

@ -0,0 +1 @@
DELETE FROM notification_templates WHERE id = 'd089fe7b-d5c5-4c0c-aaf5-689859f7d392';

View File

@ -0,0 +1,30 @@
INSERT INTO notification_templates
(id, name, title_template, body_template, "group", actions)
VALUES (
'd089fe7b-d5c5-4c0c-aaf5-689859f7d392',
'Workspace Manually Updated',
E'Workspace ''{{.Labels.workspace}}'' has been manually updated',
E'Hello {{.UserName}},\n\n'||
E'A new workspace build has been manually created for your workspace **{{.Labels.workspace}}** by **{{.Labels.initiator}}** to update it to version **{{.Labels.version}}** of template **{{.Labels.template}}**.',
'Workspace Events',
'[
{
"label": "View workspace",
"url": "{{base_url}}/@{{.UserUsername}}/{{.Labels.workspace}}"
},
{
"label": "View template version",
"url": "{{base_url}}/templates/{{.Labels.organization}}/{{.Labels.template}}/versions/{{.Labels.version}}"
}
]'::jsonb
);
UPDATE notification_templates
SET
actions = '[
{
"label": "View workspace",
"url": "{{base_url}}/@{{.UserUsername}}/{{.Labels.workspace}}"
}
]'::jsonb
WHERE id = '281fdf73-c6d6-4cbb-8ff5-888baf8a2fff';

View File

@ -8,6 +8,7 @@ import "github.com/google/uuid"
// Workspace-related events. // Workspace-related events.
var ( var (
TemplateWorkspaceCreated = uuid.MustParse("281fdf73-c6d6-4cbb-8ff5-888baf8a2fff") TemplateWorkspaceCreated = uuid.MustParse("281fdf73-c6d6-4cbb-8ff5-888baf8a2fff")
TemplateWorkspaceManuallyUpdated = uuid.MustParse("d089fe7b-d5c5-4c0c-aaf5-689859f7d392")
TemplateWorkspaceDeleted = uuid.MustParse("f517da0b-cdc9-410f-ab89-a86107c420ed") TemplateWorkspaceDeleted = uuid.MustParse("f517da0b-cdc9-410f-ab89-a86107c420ed")
TemplateWorkspaceAutobuildFailed = uuid.MustParse("381df2a9-c0c0-4749-420f-80a9280c66f9") TemplateWorkspaceAutobuildFailed = uuid.MustParse("381df2a9-c0c0-4749-420f-80a9280c66f9")
TemplateWorkspaceDormant = uuid.MustParse("0ea69165-ec14-4314-91f1-69566ac3c5a0") TemplateWorkspaceDormant = uuid.MustParse("0ea69165-ec14-4314-91f1-69566ac3c5a0")

View File

@ -1048,6 +1048,22 @@ func TestNotificationTemplates_Golden(t *testing.T) {
}, },
}, },
}, },
{
name: "TemplateWorkspaceManuallyUpdated",
id: notifications.TemplateWorkspaceManuallyUpdated,
payload: types.MessagePayload{
UserName: "Bobby",
UserEmail: "bobby@coder.com",
UserUsername: "bobby",
Labels: map[string]string{
"organization": "bobby-organization",
"initiator": "bobby",
"workspace": "bobby-workspace",
"template": "bobby-template",
"version": "alpha",
},
},
},
} }
// We must have a test case for every notification_template. This is enforced below: // We must have a test case for every notification_template. This is enforced below:

View File

@ -16,7 +16,7 @@ The workspace bobby-workspace has been created from the template bobby-temp=
late using version alpha. late using version alpha.
See workspace: http://test.com/@bobby/bobby-workspace View workspace: http://test.com/@bobby/bobby-workspace
--bbe61b741255b6098bb6b3c1f41b885773df633cb18d2a3002b68e4bc9c4 --bbe61b741255b6098bb6b3c1f41b885773df633cb18d2a3002b68e4bc9c4
Content-Transfer-Encoding: quoted-printable Content-Transfer-Encoding: quoted-printable
@ -57,7 +57,7 @@ ng>.</p>
<a href=3D"http://test.com/@bobby/bobby-workspace" style=3D"display= <a href=3D"http://test.com/@bobby/bobby-workspace" style=3D"display=
: inline-block; padding: 13px 24px; background-color: #020617; color: #f8fa= : inline-block; padding: 13px 24px; background-color: #020617; color: #f8fa=
fc; text-decoration: none; border-radius: 8px; margin: 0 4px;"> fc; text-decoration: none; border-radius: 8px; margin: 0 4px;">
See workspace View workspace
</a> </a>
=20 =20
</div> </div>

View File

@ -0,0 +1,90 @@
From: system@coder.com
To: bobby@coder.com
Subject: Workspace 'bobby-workspace' has been manually updated
Message-Id: 02ee4935-73be-4fa1-a290-ff9999026b13@blush-whale-48
Date: Fri, 11 Oct 2024 09:03:06 +0000
Content-Type: multipart/alternative; boundary=bbe61b741255b6098bb6b3c1f41b885773df633cb18d2a3002b68e4bc9c4
MIME-Version: 1.0
--bbe61b741255b6098bb6b3c1f41b885773df633cb18d2a3002b68e4bc9c4
Content-Transfer-Encoding: quoted-printable
Content-Type: text/plain; charset=UTF-8
Hello Bobby,
A new workspace build has been manually created for your workspace bobby-wo=
rkspace by bobby to update it to version alpha of template bobby-template.
View workspace: http://test.com/@bobby/bobby-workspace
View template version: http://test.com/templates/bobby-organization/bobby-t=
emplate/versions/alpha
--bbe61b741255b6098bb6b3c1f41b885773df633cb18d2a3002b68e4bc9c4
Content-Transfer-Encoding: quoted-printable
Content-Type: text/html; charset=UTF-8
<!doctype html>
<html lang=3D"en">
<head>
<meta charset=3D"UTF-8" />
<meta name=3D"viewport" content=3D"width=3Ddevice-width, initial-scale=
=3D1.0" />
<title>Workspace 'bobby-workspace' has been manually updated</title>
</head>
<body style=3D"margin: 0; padding: 0; font-family: -apple-system, system-=
ui, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarel=
l', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif; color: #020617=
; background: #f8fafc;">
<div style=3D"max-width: 600px; margin: 20px auto; padding: 60px; borde=
r: 1px solid #e2e8f0; border-radius: 8px; background-color: #fff; text-alig=
n: left; font-size: 14px; line-height: 1.5;">
<div style=3D"text-align: center;">
<img src=3D"https://coder.com/coder-logo-horizontal.png" alt=3D"Cod=
er Logo" style=3D"height: 40px;" />
</div>
<h1 style=3D"text-align: center; font-size: 24px; font-weight: 400; m=
argin: 8px 0 32px; line-height: 1.5;">
Workspace 'bobby-workspace' has been manually updated
</h1>
<div style=3D"line-height: 1.5;">
<p>Hello Bobby,</p>
<p>A new workspace build has been manually created for your workspace <stro=
ng>bobby-workspace</strong> by <strong>bobby</strong> to update it to versi=
on <strong>alpha</strong> of template <strong>bobby-template</strong>.</p>
</div>
<div style=3D"text-align: center; margin-top: 32px;">
=20
<a href=3D"http://test.com/@bobby/bobby-workspace" style=3D"display=
: inline-block; padding: 13px 24px; background-color: #020617; color: #f8fa=
fc; text-decoration: none; border-radius: 8px; margin: 0 4px;">
View workspace
</a>
=20
<a href=3D"http://test.com/templates/bobby-organization/bobby-templ=
ate/versions/alpha" style=3D"display: inline-block; padding: 13px 24px; bac=
kground-color: #020617; color: #f8fafc; text-decoration: none; border-radiu=
s: 8px; margin: 0 4px;">
View template version
</a>
=20
</div>
<div style=3D"border-top: 1px solid #e2e8f0; color: #475569; font-siz=
e: 12px; margin-top: 64px; padding-top: 24px; line-height: 1.6;">
<p>&copy;&nbsp;2024&nbsp;Coder. All rights reserved&nbsp;-&nbsp;<a =
href=3D"http://test.com" style=3D"color: #2563eb; text-decoration: none;">h=
ttp://test.com</a></p>
<p><a href=3D"http://test.com/settings/notifications" style=3D"colo=
r: #2563eb; text-decoration: none;">Click here to manage your notification =
settings</a></p>
<p><a href=3D"http://test.com/settings/notifications?disabled=3Dd08=
9fe7b-d5c5-4c0c-aaf5-689859f7d392" style=3D"color: #2563eb; text-decoration=
: none;">Stop receiving emails like this</a></p>
</div>
</div>
</body>
</html>
--bbe61b741255b6098bb6b3c1f41b885773df633cb18d2a3002b68e4bc9c4--

View File

@ -11,7 +11,7 @@
"user_username": "bobby", "user_username": "bobby",
"actions": [ "actions": [
{ {
"label": "See workspace", "label": "View workspace",
"url": "http://test.com/@bobby/bobby-workspace" "url": "http://test.com/@bobby/bobby-workspace"
} }
], ],

View File

@ -0,0 +1,35 @@
{
"_version": "1.1",
"msg_id": "00000000-0000-0000-0000-000000000000",
"payload": {
"_version": "1.1",
"notification_name": "Workspace Manually Updated",
"notification_template_id": "00000000-0000-0000-0000-000000000000",
"user_id": "00000000-0000-0000-0000-000000000000",
"user_email": "bobby@coder.com",
"user_name": "Bobby",
"user_username": "bobby",
"actions": [
{
"label": "View workspace",
"url": "http://test.com/@bobby/bobby-workspace"
},
{
"label": "View template version",
"url": "http://test.com/templates/bobby-organization/bobby-template/versions/alpha"
}
],
"labels": {
"initiator": "bobby",
"organization": "bobby-organization",
"template": "bobby-template",
"version": "alpha",
"workspace": "bobby-workspace"
},
"data": null
},
"title": "Workspace 'bobby-workspace' has been manually updated",
"title_markdown": "Workspace 'bobby-workspace' has been manually updated",
"body": "Hello Bobby,\n\nA new workspace build has been manually created for your workspace bobby-workspace by bobby to update it to version alpha of template bobby-template.",
"body_markdown": "Hello Bobby,\n\nA new workspace build has been manually created for your workspace **bobby-workspace** by **bobby** to update it to version **alpha** of template **bobby-template**."
}

View File

@ -27,6 +27,7 @@ import (
"github.com/coder/coder/v2/coderd/database/provisionerjobs" "github.com/coder/coder/v2/coderd/database/provisionerjobs"
"github.com/coder/coder/v2/coderd/httpapi" "github.com/coder/coder/v2/coderd/httpapi"
"github.com/coder/coder/v2/coderd/httpmw" "github.com/coder/coder/v2/coderd/httpmw"
"github.com/coder/coder/v2/coderd/notifications"
"github.com/coder/coder/v2/coderd/provisionerdserver" "github.com/coder/coder/v2/coderd/provisionerdserver"
"github.com/coder/coder/v2/coderd/rbac" "github.com/coder/coder/v2/coderd/rbac"
"github.com/coder/coder/v2/coderd/rbac/policy" "github.com/coder/coder/v2/coderd/rbac/policy"
@ -333,6 +334,26 @@ func (api *API) postWorkspaceBuilds(rw http.ResponseWriter, r *http.Request) {
LogLevel(string(createBuild.LogLevel)). LogLevel(string(createBuild.LogLevel)).
DeploymentValues(api.Options.DeploymentValues) DeploymentValues(api.Options.DeploymentValues)
var (
previousWorkspaceBuild database.WorkspaceBuild
workspaceBuild *database.WorkspaceBuild
provisionerJob *database.ProvisionerJob
provisionerDaemons []database.GetEligibleProvisionerDaemonsByProvisionerJobIDsRow
)
err := api.Database.InTx(func(tx database.Store) error {
var err error
previousWorkspaceBuild, err = tx.GetLatestWorkspaceBuildByWorkspaceID(ctx, workspace.ID)
if err != nil && !xerrors.Is(err, sql.ErrNoRows) {
api.Logger.Error(ctx, "failed fetching previous workspace build", slog.F("workspace_id", workspace.ID), slog.Error(err))
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching previous workspace build",
Detail: err.Error(),
})
return nil
}
if createBuild.TemplateVersionID != uuid.Nil { if createBuild.TemplateVersionID != uuid.Nil {
builder = builder.VersionID(createBuild.TemplateVersionID) builder = builder.VersionID(createBuild.TemplateVersionID)
} }
@ -342,13 +363,13 @@ func (api *API) postWorkspaceBuilds(rw http.ResponseWriter, r *http.Request) {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Orphan is only permitted when deleting a workspace.", Message: "Orphan is only permitted when deleting a workspace.",
}) })
return return nil
} }
if len(createBuild.ProvisionerState) > 0 { if len(createBuild.ProvisionerState) > 0 {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "ProvisionerState cannot be set alongside Orphan since state intent is unclear.", Message: "ProvisionerState cannot be set alongside Orphan since state intent is unclear.",
}) })
return return nil
} }
builder = builder.Orphan() builder = builder.Orphan()
} }
@ -356,14 +377,16 @@ func (api *API) postWorkspaceBuilds(rw http.ResponseWriter, r *http.Request) {
builder = builder.State(createBuild.ProvisionerState) builder = builder.State(createBuild.ProvisionerState)
} }
workspaceBuild, provisionerJob, provisionerDaemons, err := builder.Build( workspaceBuild, provisionerJob, provisionerDaemons, err = builder.Build(
ctx, ctx,
api.Database, tx,
func(action policy.Action, object rbac.Objecter) bool { func(action policy.Action, object rbac.Objecter) bool {
return api.Authorize(r, action, object) return api.Authorize(r, action, object)
}, },
audit.WorkspaceBuildBaggageFromRequest(r), audit.WorkspaceBuildBaggageFromRequest(r),
) )
return err
}, nil)
var buildErr wsbuilder.BuildError var buildErr wsbuilder.BuildError
if xerrors.As(err, &buildErr) { if xerrors.As(err, &buildErr) {
var authErr dbauthz.NotAuthorizedError var authErr dbauthz.NotAuthorizedError
@ -420,6 +443,12 @@ func (api *API) postWorkspaceBuilds(rw http.ResponseWriter, r *http.Request) {
return return
} }
// If this workspace build has a different template version ID to the previous build
// we can assume it has just been updated.
if createBuild.TemplateVersionID != uuid.Nil && createBuild.TemplateVersionID != previousWorkspaceBuild.TemplateVersionID {
api.notifyWorkspaceUpdated(ctx, apiKey.UserID, workspace, createBuild.RichParameterValues)
}
api.publishWorkspaceUpdate(ctx, workspace.OwnerID, wspubsub.WorkspaceEvent{ api.publishWorkspaceUpdate(ctx, workspace.OwnerID, wspubsub.WorkspaceEvent{
Kind: wspubsub.WorkspaceEventKindStateChange, Kind: wspubsub.WorkspaceEventKindStateChange,
WorkspaceID: workspace.ID, WorkspaceID: workspace.ID,
@ -428,6 +457,73 @@ func (api *API) postWorkspaceBuilds(rw http.ResponseWriter, r *http.Request) {
httpapi.Write(ctx, rw, http.StatusCreated, apiBuild) httpapi.Write(ctx, rw, http.StatusCreated, apiBuild)
} }
func (api *API) notifyWorkspaceUpdated(
ctx context.Context,
initiatorID uuid.UUID,
workspace database.Workspace,
parameters []codersdk.WorkspaceBuildParameter,
) {
log := api.Logger.With(slog.F("workspace_id", workspace.ID))
template, err := api.Database.GetTemplateByID(ctx, workspace.TemplateID)
if err != nil {
log.Warn(ctx, "failed to fetch template for workspace creation notification", slog.F("template_id", workspace.TemplateID), slog.Error(err))
return
}
version, err := api.Database.GetTemplateVersionByID(ctx, template.ActiveVersionID)
if err != nil {
log.Warn(ctx, "failed to fetch template version for workspace creation notification", slog.F("template_id", workspace.TemplateID), slog.Error(err))
return
}
initiator, err := api.Database.GetUserByID(ctx, initiatorID)
if err != nil {
log.Warn(ctx, "failed to fetch user for workspace update notification", slog.F("initiator_id", initiatorID), slog.Error(err))
return
}
owner, err := api.Database.GetUserByID(ctx, workspace.OwnerID)
if err != nil {
log.Warn(ctx, "failed to fetch user for workspace update notification", slog.F("owner_id", workspace.OwnerID), slog.Error(err))
return
}
buildParameters := make([]map[string]any, len(parameters))
for idx, parameter := range parameters {
buildParameters[idx] = map[string]any{
"name": parameter.Name,
"value": parameter.Value,
}
}
if _, err := api.NotificationsEnqueuer.EnqueueWithData(
// nolint:gocritic // Need notifier actor to enqueue notifications
dbauthz.AsNotifier(ctx),
workspace.OwnerID,
notifications.TemplateWorkspaceManuallyUpdated,
map[string]string{
"organization": template.OrganizationName,
"initiator": initiator.Name,
"workspace": workspace.Name,
"template": template.Name,
"version": version.Name,
},
map[string]any{
"workspace": map[string]any{"id": workspace.ID, "name": workspace.Name},
"template": map[string]any{"id": template.ID, "name": template.Name},
"template_version": map[string]any{"id": version.ID, "name": version.Name},
"owner": map[string]any{"id": owner.ID, "name": owner.Name},
"parameters": buildParameters,
},
"api-workspaces-updated",
// Associate this notification with all the related entities
workspace.ID, workspace.OwnerID, workspace.TemplateID, workspace.OrganizationID,
); err != nil {
log.Warn(ctx, "failed to notify of workspace update", slog.Error(err))
}
}
// @Summary Cancel workspace build // @Summary Cancel workspace build
// @ID cancel-workspace-build // @ID cancel-workspace-build
// @Security CoderSessionToken // @Security CoderSessionToken

View File

@ -27,6 +27,8 @@ import (
"github.com/coder/coder/v2/coderd/database/dbtestutil" "github.com/coder/coder/v2/coderd/database/dbtestutil"
"github.com/coder/coder/v2/coderd/database/dbtime" "github.com/coder/coder/v2/coderd/database/dbtime"
"github.com/coder/coder/v2/coderd/externalauth" "github.com/coder/coder/v2/coderd/externalauth"
"github.com/coder/coder/v2/coderd/notifications"
"github.com/coder/coder/v2/coderd/notifications/notificationstest"
"github.com/coder/coder/v2/coderd/rbac" "github.com/coder/coder/v2/coderd/rbac"
"github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/provisioner/echo" "github.com/coder/coder/v2/provisioner/echo"
@ -560,6 +562,104 @@ func TestWorkspaceBuildResources(t *testing.T) {
}) })
} }
func TestWorkspaceBuildWithUpdatedTemplateVersionSendsNotification(t *testing.T) {
t.Parallel()
t.Run("OnlyOneNotification", func(t *testing.T) {
t.Parallel()
notify := &notificationstest.FakeEnqueuer{}
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true, NotificationsEnqueuer: notify})
first := coderdtest.CreateFirstUser(t, client)
userClient, user := coderdtest.CreateAnotherUser(t, client, first.OrganizationID)
// Create a template with an initial version
version := coderdtest.CreateTemplateVersion(t, client, first.OrganizationID, nil)
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
template := coderdtest.CreateTemplate(t, client, first.OrganizationID, version.ID)
// Create a workspace using this template
workspace := coderdtest.CreateWorkspace(t, userClient, template.ID)
coderdtest.AwaitWorkspaceBuildJobCompleted(t, userClient, workspace.LatestBuild.ID)
coderdtest.MustTransitionWorkspace(t, userClient, workspace.ID, database.WorkspaceTransitionStart, database.WorkspaceTransitionStop)
// Create a new version of the template
newVersion := coderdtest.CreateTemplateVersion(t, client, first.OrganizationID, nil, func(ctvr *codersdk.CreateTemplateVersionRequest) {
ctvr.TemplateID = template.ID
})
coderdtest.AwaitTemplateVersionJobCompleted(t, client, newVersion.ID)
// Create a workspace build using this new template version
build := coderdtest.CreateWorkspaceBuild(t, userClient, workspace, database.WorkspaceTransitionStart, func(cwbr *codersdk.CreateWorkspaceBuildRequest) {
cwbr.TemplateVersionID = newVersion.ID
})
coderdtest.AwaitWorkspaceBuildJobCompleted(t, userClient, build.ID)
coderdtest.MustTransitionWorkspace(t, userClient, workspace.ID, database.WorkspaceTransitionStart, database.WorkspaceTransitionStop)
// Create the workspace build _again_. We are doing this to ensure we only create 1 notification.
build = coderdtest.CreateWorkspaceBuild(t, userClient, workspace, database.WorkspaceTransitionStart, func(cwbr *codersdk.CreateWorkspaceBuildRequest) {
cwbr.TemplateVersionID = newVersion.ID
})
coderdtest.AwaitWorkspaceBuildJobCompleted(t, userClient, build.ID)
coderdtest.MustTransitionWorkspace(t, userClient, workspace.ID, database.WorkspaceTransitionStart, database.WorkspaceTransitionStop)
// Ensure we receive only 1 workspace manually updated notification
sent := notify.Sent(notificationstest.WithTemplateID(notifications.TemplateWorkspaceManuallyUpdated))
require.Len(t, sent, 1)
require.Equal(t, user.ID, sent[0].UserID)
require.Contains(t, sent[0].Targets, template.ID)
require.Contains(t, sent[0].Targets, workspace.ID)
require.Contains(t, sent[0].Targets, workspace.OrganizationID)
require.Contains(t, sent[0].Targets, workspace.OwnerID)
})
t.Run("ToCorrectUser", func(t *testing.T) {
t.Parallel()
notify := &notificationstest.FakeEnqueuer{}
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true, NotificationsEnqueuer: notify})
first := coderdtest.CreateFirstUser(t, client)
userClient, user := coderdtest.CreateAnotherUser(t, client, first.OrganizationID)
// Create a template with an initial version
version := coderdtest.CreateTemplateVersion(t, client, first.OrganizationID, nil)
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
template := coderdtest.CreateTemplate(t, client, first.OrganizationID, version.ID)
// Create a workspace using this template
workspace := coderdtest.CreateWorkspace(t, userClient, template.ID)
coderdtest.AwaitWorkspaceBuildJobCompleted(t, userClient, workspace.LatestBuild.ID)
coderdtest.MustTransitionWorkspace(t, userClient, workspace.ID, database.WorkspaceTransitionStart, database.WorkspaceTransitionStop)
// Create a new version of the template
newVersion := coderdtest.CreateTemplateVersion(t, client, first.OrganizationID, nil, func(ctvr *codersdk.CreateTemplateVersionRequest) {
ctvr.TemplateID = template.ID
})
coderdtest.AwaitTemplateVersionJobCompleted(t, client, newVersion.ID)
// Create a workspace build using this new template version from a different user
ctx := testutil.Context(t, testutil.WaitShort)
build, err := client.CreateWorkspaceBuild(ctx, workspace.ID, codersdk.CreateWorkspaceBuildRequest{
Transition: codersdk.WorkspaceTransitionStart,
TemplateVersionID: newVersion.ID,
})
require.NoError(t, err)
coderdtest.AwaitWorkspaceBuildJobCompleted(t, userClient, build.ID)
coderdtest.MustTransitionWorkspace(t, userClient, workspace.ID, database.WorkspaceTransitionStart, database.WorkspaceTransitionStop)
// Ensure we receive only 1 workspace manually updated notification and to the right user
sent := notify.Sent(notificationstest.WithTemplateID(notifications.TemplateWorkspaceManuallyUpdated))
require.Len(t, sent, 1)
require.Equal(t, user.ID, sent[0].UserID)
require.Contains(t, sent[0].Targets, template.ID)
require.Contains(t, sent[0].Targets, workspace.ID)
require.Contains(t, sent[0].Targets, workspace.OrganizationID)
require.Contains(t, sent[0].Targets, workspace.OwnerID)
})
}
func assertWorkspaceResource(t *testing.T, actual codersdk.WorkspaceResource, name, aType string, numAgents int) { func assertWorkspaceResource(t *testing.T, actual codersdk.WorkspaceResource, name, aType string, numAgents int) {
assert.Equal(t, name, actual.Name) assert.Equal(t, name, actual.Name)
assert.Equal(t, aType, actual.Type) assert.Equal(t, aType, actual.Type)