diff --git a/coderd/database/migrations/000280_workspace_update_notification.down.sql b/coderd/database/migrations/000280_workspace_update_notification.down.sql
new file mode 100644
index 0000000000000..5097c0248fe9b
--- /dev/null
+++ b/coderd/database/migrations/000280_workspace_update_notification.down.sql
@@ -0,0 +1 @@
+DELETE FROM notification_templates WHERE id = 'd089fe7b-d5c5-4c0c-aaf5-689859f7d392';
diff --git a/coderd/database/migrations/000280_workspace_update_notification.up.sql b/coderd/database/migrations/000280_workspace_update_notification.up.sql
new file mode 100644
index 0000000000000..23d2331a323f6
--- /dev/null
+++ b/coderd/database/migrations/000280_workspace_update_notification.up.sql
@@ -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';
diff --git a/coderd/notifications/events.go b/coderd/notifications/events.go
index 12aecbaac74ae..754d2e5c7f745 100644
--- a/coderd/notifications/events.go
+++ b/coderd/notifications/events.go
@@ -8,6 +8,7 @@ import "github.com/google/uuid"
// Workspace-related events.
var (
TemplateWorkspaceCreated = uuid.MustParse("281fdf73-c6d6-4cbb-8ff5-888baf8a2fff")
+ TemplateWorkspaceManuallyUpdated = uuid.MustParse("d089fe7b-d5c5-4c0c-aaf5-689859f7d392")
TemplateWorkspaceDeleted = uuid.MustParse("f517da0b-cdc9-410f-ab89-a86107c420ed")
TemplateWorkspaceAutobuildFailed = uuid.MustParse("381df2a9-c0c0-4749-420f-80a9280c66f9")
TemplateWorkspaceDormant = uuid.MustParse("0ea69165-ec14-4314-91f1-69566ac3c5a0")
diff --git a/coderd/notifications/notifications_test.go b/coderd/notifications/notifications_test.go
index 90cf1a46be28a..1c4be51974b05 100644
--- a/coderd/notifications/notifications_test.go
+++ b/coderd/notifications/notifications_test.go
@@ -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:
diff --git a/coderd/notifications/testdata/rendered-templates/smtp/TemplateWorkspaceCreated.html.golden b/coderd/notifications/testdata/rendered-templates/smtp/TemplateWorkspaceCreated.html.golden
index 000b2a71ac77b..9d039ea7f77e9 100644
--- a/coderd/notifications/testdata/rendered-templates/smtp/TemplateWorkspaceCreated.html.golden
+++ b/coderd/notifications/testdata/rendered-templates/smtp/TemplateWorkspaceCreated.html.golden
@@ -16,7 +16,7 @@ The workspace bobby-workspace has been created from the template bobby-temp=
late using version alpha.
-See workspace: http://test.com/@bobby/bobby-workspace
+View workspace: http://test.com/@bobby/bobby-workspace
--bbe61b741255b6098bb6b3c1f41b885773df633cb18d2a3002b68e4bc9c4
Content-Transfer-Encoding: quoted-printable
@@ -57,7 +57,7 @@ ng>.
- See workspace
+ View workspace
=20
diff --git a/coderd/notifications/testdata/rendered-templates/smtp/TemplateWorkspaceManuallyUpdated.html.golden b/coderd/notifications/testdata/rendered-templates/smtp/TemplateWorkspaceManuallyUpdated.html.golden
new file mode 100644
index 0000000000000..57a9a0d51b7b7
--- /dev/null
+++ b/coderd/notifications/testdata/rendered-templates/smtp/TemplateWorkspaceManuallyUpdated.html.golden
@@ -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
+
+
+
+
+
+
+ Workspace 'bobby-workspace' has been manually updated
+
+
+
+
+

+
+
+ Workspace 'bobby-workspace' has been manually updated
+
+
+
Hello Bobby,
+
+
A new workspace build has been manually created for your workspace bobby-workspace by bobby to update it to versi=
+on alpha of template bobby-template.
+
+
+
+
+
+
+
+--bbe61b741255b6098bb6b3c1f41b885773df633cb18d2a3002b68e4bc9c4--
diff --git a/coderd/notifications/testdata/rendered-templates/webhook/TemplateWorkspaceCreated.json.golden b/coderd/notifications/testdata/rendered-templates/webhook/TemplateWorkspaceCreated.json.golden
index 46354c4ffeef9..924f299b228b2 100644
--- a/coderd/notifications/testdata/rendered-templates/webhook/TemplateWorkspaceCreated.json.golden
+++ b/coderd/notifications/testdata/rendered-templates/webhook/TemplateWorkspaceCreated.json.golden
@@ -11,7 +11,7 @@
"user_username": "bobby",
"actions": [
{
- "label": "See workspace",
+ "label": "View workspace",
"url": "http://test.com/@bobby/bobby-workspace"
}
],
diff --git a/coderd/notifications/testdata/rendered-templates/webhook/TemplateWorkspaceManuallyUpdated.json.golden b/coderd/notifications/testdata/rendered-templates/webhook/TemplateWorkspaceManuallyUpdated.json.golden
new file mode 100644
index 0000000000000..7fbda32e194f4
--- /dev/null
+++ b/coderd/notifications/testdata/rendered-templates/webhook/TemplateWorkspaceManuallyUpdated.json.golden
@@ -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**."
+}
\ No newline at end of file
diff --git a/coderd/workspacebuilds.go b/coderd/workspacebuilds.go
index 7eb598a7d4564..0a19f7dfdaf0a 100644
--- a/coderd/workspacebuilds.go
+++ b/coderd/workspacebuilds.go
@@ -27,6 +27,7 @@ import (
"github.com/coder/coder/v2/coderd/database/provisionerjobs"
"github.com/coder/coder/v2/coderd/httpapi"
"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/rbac"
"github.com/coder/coder/v2/coderd/rbac/policy"
@@ -333,37 +334,59 @@ func (api *API) postWorkspaceBuilds(rw http.ResponseWriter, r *http.Request) {
LogLevel(string(createBuild.LogLevel)).
DeploymentValues(api.Options.DeploymentValues)
- if createBuild.TemplateVersionID != uuid.Nil {
- builder = builder.VersionID(createBuild.TemplateVersionID)
- }
+ var (
+ previousWorkspaceBuild database.WorkspaceBuild
+ workspaceBuild *database.WorkspaceBuild
+ provisionerJob *database.ProvisionerJob
+ provisionerDaemons []database.GetEligibleProvisionerDaemonsByProvisionerJobIDsRow
+ )
- if createBuild.Orphan {
- if createBuild.Transition != codersdk.WorkspaceTransitionDelete {
- httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
- Message: "Orphan is only permitted when deleting a workspace.",
+ 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
+ return nil
+ }
+
+ if createBuild.TemplateVersionID != uuid.Nil {
+ builder = builder.VersionID(createBuild.TemplateVersionID)
+ }
+
+ if createBuild.Orphan {
+ if createBuild.Transition != codersdk.WorkspaceTransitionDelete {
+ httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
+ Message: "Orphan is only permitted when deleting a workspace.",
+ })
+ return nil
+ }
+ if len(createBuild.ProvisionerState) > 0 {
+ httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
+ Message: "ProvisionerState cannot be set alongside Orphan since state intent is unclear.",
+ })
+ return nil
+ }
+ builder = builder.Orphan()
}
if len(createBuild.ProvisionerState) > 0 {
- httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
- Message: "ProvisionerState cannot be set alongside Orphan since state intent is unclear.",
- })
- return
+ builder = builder.State(createBuild.ProvisionerState)
}
- builder = builder.Orphan()
- }
- if len(createBuild.ProvisionerState) > 0 {
- builder = builder.State(createBuild.ProvisionerState)
- }
- workspaceBuild, provisionerJob, provisionerDaemons, err := builder.Build(
- ctx,
- api.Database,
- func(action policy.Action, object rbac.Objecter) bool {
- return api.Authorize(r, action, object)
- },
- audit.WorkspaceBuildBaggageFromRequest(r),
- )
+ workspaceBuild, provisionerJob, provisionerDaemons, err = builder.Build(
+ ctx,
+ tx,
+ func(action policy.Action, object rbac.Objecter) bool {
+ return api.Authorize(r, action, object)
+ },
+ audit.WorkspaceBuildBaggageFromRequest(r),
+ )
+ return err
+ }, nil)
var buildErr wsbuilder.BuildError
if xerrors.As(err, &buildErr) {
var authErr dbauthz.NotAuthorizedError
@@ -420,6 +443,12 @@ func (api *API) postWorkspaceBuilds(rw http.ResponseWriter, r *http.Request) {
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{
Kind: wspubsub.WorkspaceEventKindStateChange,
WorkspaceID: workspace.ID,
@@ -428,6 +457,73 @@ func (api *API) postWorkspaceBuilds(rw http.ResponseWriter, r *http.Request) {
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
// @ID cancel-workspace-build
// @Security CoderSessionToken
diff --git a/coderd/workspacebuilds_test.go b/coderd/workspacebuilds_test.go
index feb748ad29250..43674b308583c 100644
--- a/coderd/workspacebuilds_test.go
+++ b/coderd/workspacebuilds_test.go
@@ -27,6 +27,8 @@ import (
"github.com/coder/coder/v2/coderd/database/dbtestutil"
"github.com/coder/coder/v2/coderd/database/dbtime"
"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/codersdk"
"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 := ¬ificationstest.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 := ¬ificationstest.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) {
assert.Equal(t, name, actual.Name)
assert.Equal(t, aType, actual.Type)