From 0691f78dbfce73f81e15df3956f9a71b6ce32e8c Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Thu, 18 Jun 2026 03:16:53 +0000 Subject: [PATCH] fix(enterprise/coderd): allow deleting external-agent workspaces --- enterprise/coderd/coderd.go | 6 +-- enterprise/coderd/coderd_test.go | 73 ++++++++++++++++++++++---------- 2 files changed, 53 insertions(+), 26 deletions(-) diff --git a/enterprise/coderd/coderd.go b/enterprise/coderd/coderd.go index 40d1e7f0979d8..e84b0745b1092 100644 --- a/enterprise/coderd/coderd.go +++ b/enterprise/coderd/coderd.go @@ -1104,9 +1104,9 @@ func (api *API) CheckBuildUsage( task *database.Task, transition database.WorkspaceTransition, ) (wsbuilder.UsageCheckResponse, error) { - // If the template version has an external agent, we need to check that the - // license is entitled to this feature. - if templateVersion.HasExternalAgent.Valid && templateVersion.HasExternalAgent.Bool { + // External-agent templates require an entitlement for start builds. + if transition == database.WorkspaceTransitionStart && + templateVersion.HasExternalAgent.Valid && templateVersion.HasExternalAgent.Bool { feature, ok := api.Entitlements.Feature(codersdk.FeatureWorkspaceExternalAgent) if !ok || !feature.Enabled { return wsbuilder.UsageCheckResponse{ diff --git a/enterprise/coderd/coderd_test.go b/enterprise/coderd/coderd_test.go index 7cdda8e64dda8..d0956057fcaed 100644 --- a/enterprise/coderd/coderd_test.go +++ b/enterprise/coderd/coderd_test.go @@ -942,37 +942,68 @@ func TestCheckBuildUsage_NeverBlocksOnManagedAgentLimit(t *testing.T) { require.True(t, deleteResp.Permitted) } -func TestCheckBuildUsage_BlocksWithoutManagedAgentEntitlement(t *testing.T) { +func TestCheckBuildUsage_BlocksStartWithoutEntitlement(t *testing.T) { t.Parallel() - tv := &database.TemplateVersion{ + managedAgentVersion := &database.TemplateVersion{ HasAITask: sql.NullBool{Valid: true, Bool: true}, HasExternalAgent: sql.NullBool{Valid: true, Bool: false}, } - task := &database.Task{ - TemplateVersionID: tv.ID, + externalAgentVersion := &database.TemplateVersion{ + HasExternalAgent: sql.NullBool{Valid: true, Bool: true}, } - // Both "feature absent" and "feature explicitly disabled" should - // block AI task builds on licensed deployments. tests := []struct { - name string - setupEnts func(e *codersdk.Entitlements) + name string + templateVersion *database.TemplateVersion + task *database.Task + setupEnts func(e *codersdk.Entitlements) + message string + checkNoTaskStart bool }{ { - name: "FeatureAbsent", + name: "ManagedAgentFeatureAbsent", + templateVersion: managedAgentVersion, + task: &database.Task{TemplateVersionID: managedAgentVersion.ID}, setupEnts: func(e *codersdk.Entitlements) { e.HasLicense = true + delete(e.Features, codersdk.FeatureManagedAgentLimit) }, + message: "not entitled to managed agents", + checkNoTaskStart: true, }, { - name: "FeatureDisabled", + name: "ManagedAgentFeatureDisabled", + templateVersion: managedAgentVersion, + task: &database.Task{TemplateVersionID: managedAgentVersion.ID}, setupEnts: func(e *codersdk.Entitlements) { e.HasLicense = true e.Features[codersdk.FeatureManagedAgentLimit] = codersdk.Feature{ Enabled: false, } }, + message: "not entitled to managed agents", + checkNoTaskStart: true, + }, + { + name: "ExternalAgentFeatureAbsent", + templateVersion: externalAgentVersion, + setupEnts: func(e *codersdk.Entitlements) { + e.HasLicense = true + delete(e.Features, codersdk.FeatureWorkspaceExternalAgent) + }, + message: "uses external agents", + }, + { + name: "ExternalAgentFeatureDisabled", + templateVersion: externalAgentVersion, + setupEnts: func(e *codersdk.Entitlements) { + e.HasLicense = true + e.Features[codersdk.FeatureWorkspaceExternalAgent] = codersdk.Feature{ + Enabled: false, + } + }, + message: "uses external agents", }, } @@ -998,28 +1029,24 @@ func TestCheckBuildUsage_BlocksWithoutManagedAgentEntitlement(t *testing.T) { mDB := dbmock.NewMockStore(ctrl) ctx := context.Background() - // Start transition with a task: should be blocked because the - // license doesn't include the managed agent entitlement. - resp, err := eapi.CheckBuildUsage(ctx, mDB, tv, task, database.WorkspaceTransitionStart) + resp, err := eapi.CheckBuildUsage(ctx, mDB, tc.templateVersion, tc.task, database.WorkspaceTransitionStart) require.NoError(t, err) require.False(t, resp.Permitted) - require.Contains(t, resp.Message, "not entitled to managed agents") + require.Contains(t, resp.Message, tc.message) - // Stop and delete transitions should still be permitted so - // that existing workspaces can be stopped/cleaned up. - stopResp, err := eapi.CheckBuildUsage(ctx, mDB, tv, task, database.WorkspaceTransitionStop) + stopResp, err := eapi.CheckBuildUsage(ctx, mDB, tc.templateVersion, tc.task, database.WorkspaceTransitionStop) require.NoError(t, err) require.True(t, stopResp.Permitted) - deleteResp, err := eapi.CheckBuildUsage(ctx, mDB, tv, task, database.WorkspaceTransitionDelete) + deleteResp, err := eapi.CheckBuildUsage(ctx, mDB, tc.templateVersion, tc.task, database.WorkspaceTransitionDelete) require.NoError(t, err) require.True(t, deleteResp.Permitted) - // Start transition without a task: should be permitted (not - // an AI task build, so the entitlement check doesn't apply). - noTaskResp, err := eapi.CheckBuildUsage(ctx, mDB, tv, nil, database.WorkspaceTransitionStart) - require.NoError(t, err) - require.True(t, noTaskResp.Permitted) + if tc.checkNoTaskStart { + noTaskResp, err := eapi.CheckBuildUsage(ctx, mDB, tc.templateVersion, nil, database.WorkspaceTransitionStart) + require.NoError(t, err) + require.True(t, noTaskResp.Permitted) + } }) } }