diff --git a/coderd/templates.go b/coderd/templates.go index 2bcaf2099f..191615b635 100644 --- a/coderd/templates.go +++ b/coderd/templates.go @@ -90,11 +90,17 @@ func (api *API) deleteTemplate(rw http.ResponseWriter, r *http.Request) { }) return } - if len(workspaces) > 0 { - httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ - Message: "All workspaces must be deleted before a template can be removed.", - }) - return + // Allow deletion when only prebuild workspaces remain. Prebuilds + // are owned by the system user and will be cleaned up + // asynchronously by the prebuilds reconciler once the template's + // deleted flag is set. + for _, ws := range workspaces { + if ws.OwnerID != database.PrebuildsSystemUserID { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "All workspaces must be deleted before a template can be removed.", + }) + return + } } err = api.Database.UpdateTemplateDeletedByID(ctx, database.UpdateTemplateDeletedByIDParams{ ID: template.ID, diff --git a/coderd/templates_test.go b/coderd/templates_test.go index d53ecf80d2..08e198f79e 100644 --- a/coderd/templates_test.go +++ b/coderd/templates_test.go @@ -1802,6 +1802,67 @@ func TestDeleteTemplate(t *testing.T) { require.Equal(t, http.StatusForbidden, apiErr.StatusCode()) }) + t.Run("OnlyPrebuilds", func(t *testing.T) { + t.Parallel() + client, db := coderdtest.NewWithDatabase(t, nil) + owner := coderdtest.CreateFirstUser(t, client) + tpl := dbfake.TemplateVersion(t, db). + Seed(database.TemplateVersion{ + CreatedBy: owner.UserID, + OrganizationID: owner.OrganizationID, + }).Do() + + // Create a workspace owned by the prebuilds system user. + dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{ + OwnerID: database.PrebuildsSystemUserID, + OrganizationID: owner.OrganizationID, + TemplateID: tpl.Template.ID, + }).Seed(database.WorkspaceBuild{ + TemplateVersionID: tpl.TemplateVersion.ID, + }).Do() + + ctx := testutil.Context(t, testutil.WaitLong) + + err := client.DeleteTemplate(ctx, tpl.Template.ID) + require.NoError(t, err) + }) + + t.Run("PrebuildsAndHumanWorkspaces", func(t *testing.T) { + t.Parallel() + client, db := coderdtest.NewWithDatabase(t, nil) + owner := coderdtest.CreateFirstUser(t, client) + tpl := dbfake.TemplateVersion(t, db). + Seed(database.TemplateVersion{ + CreatedBy: owner.UserID, + OrganizationID: owner.OrganizationID, + }).Do() + + // Create a prebuild workspace. + dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{ + OwnerID: database.PrebuildsSystemUserID, + OrganizationID: owner.OrganizationID, + TemplateID: tpl.Template.ID, + }).Seed(database.WorkspaceBuild{ + TemplateVersionID: tpl.TemplateVersion.ID, + }).Do() + + // Create a human-owned workspace. + dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{ + OwnerID: owner.UserID, + OrganizationID: owner.OrganizationID, + TemplateID: tpl.Template.ID, + }).Seed(database.WorkspaceBuild{ + TemplateVersionID: tpl.TemplateVersion.ID, + }).Do() + + ctx := testutil.Context(t, testutil.WaitLong) + + err := client.DeleteTemplate(ctx, tpl.Template.ID) + var apiErr *codersdk.Error + require.ErrorAs(t, err, &apiErr) + require.Equal(t, http.StatusBadRequest, apiErr.StatusCode()) + }) + t.Run("DeletedIsSet", func(t *testing.T) { t.Parallel() client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})