Skip to content

Commit 76d418f

Browse files
committed
Add --force flag to rad resource delete and rad app delete
Add a --force option to both 'rad resource delete' and 'rad app delete' commands that allows users to delete resources stuck in non-terminal provisioning states (e.g., Updating, Accepted). Server-side: DefaultAsyncDelete reads a 'force' query parameter and skips the provisioning state conflict check when force=true, while still validating ETags and running delete filters. Client-side: A forceDeletePolicy pipeline policy injects force=true as a query parameter when the force option is set. CLI: Both commands accept --force, display a warning about potential orphaned external resources, and pass the flag through to the API.
1 parent f206860 commit 76d418f

13 files changed

Lines changed: 140 additions & 40 deletions

File tree

pkg/armrpc/frontend/defaultoperation/defaultasyncdelete.go

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@ func NewDefaultAsyncDelete[P interface {
4242
}
4343

4444
// Run executes asynchronous delete operation by validating the request, executing custom delete filters, and starting async job, and returns an async response.
45+
// When the "force" query parameter is set to "true", the provisioning state check is skipped,
46+
// allowing deletion of resources stuck in non-terminal states (e.g., "Updating").
4547
func (e *DefaultAsyncDelete[P, T]) Run(ctx context.Context, w http.ResponseWriter, req *http.Request) (rest.Response, error) {
4648
serviceCtx := v1.ARMRequestContextFromContext(ctx)
4749
old, etag, err := e.GetResource(ctx, serviceCtx.ResourceID)
@@ -53,8 +55,16 @@ func (e *DefaultAsyncDelete[P, T]) Run(ctx context.Context, w http.ResponseWrite
5355
return rest.NewNoContentResponse(), nil
5456
}
5557

56-
if r, err := e.PrepareResource(ctx, req, nil, old, etag); r != nil || err != nil {
57-
return r, err
58+
force := req.URL.Query().Get("force") == "true"
59+
if force {
60+
// When force-deleting, skip the provisioning state check but still validate the ETag.
61+
if err := ctrl.ValidateETag(*serviceCtx, etag); err != nil {
62+
return rest.NewPreconditionFailedResponse(serviceCtx.ResourceID.String(), err.Error()), nil
63+
}
64+
} else {
65+
if r, err := e.PrepareResource(ctx, req, nil, old, etag); r != nil || err != nil {
66+
return r, err
67+
}
5868
}
5969

6070
for _, filter := range e.DeleteFilters() {

pkg/armrpc/frontend/defaultoperation/defaultasyncdelete_test.go

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -44,12 +44,16 @@ func TestDefaultAsyncDelete(t *testing.T) {
4444
qErr error
4545
saveErr error
4646
rejectedByFilter bool
47+
force bool
4748
code int
4849
}{
49-
{"async-delete-non-existing-resource-no-etag", "", v1.ProvisioningStateNone, &database.ErrNotFound{}, nil, nil, false, http.StatusNoContent},
50-
{"async-delete-existing-resource-blocked-by-filter", "", v1.ProvisioningStateSucceeded, nil, nil, nil, true, http.StatusConflict},
51-
{"async-delete-existing-resource-not-in-terminal-state", "", v1.ProvisioningStateUpdating, nil, nil, nil, false, http.StatusConflict},
52-
{"async-delete-existing-resource-success", "", v1.ProvisioningStateSucceeded, nil, nil, nil, false, http.StatusAccepted},
50+
{"async-delete-non-existing-resource-no-etag", "", v1.ProvisioningStateNone, &database.ErrNotFound{}, nil, nil, false, false, http.StatusNoContent},
51+
{"async-delete-existing-resource-blocked-by-filter", "", v1.ProvisioningStateSucceeded, nil, nil, nil, true, false, http.StatusConflict},
52+
{"async-delete-existing-resource-not-in-terminal-state", "", v1.ProvisioningStateUpdating, nil, nil, nil, false, false, http.StatusConflict},
53+
{"async-delete-existing-resource-success", "", v1.ProvisioningStateSucceeded, nil, nil, nil, false, false, http.StatusAccepted},
54+
{"async-force-delete-existing-resource-in-updating-state", "", v1.ProvisioningStateUpdating, nil, nil, nil, false, true, http.StatusAccepted},
55+
{"async-force-delete-existing-resource-in-accepted-state", "", v1.ProvisioningStateAccepted, nil, nil, nil, false, true, http.StatusAccepted},
56+
{"async-force-delete-existing-resource-blocked-by-filter", "", v1.ProvisioningStateUpdating, nil, nil, nil, true, true, http.StatusConflict},
5357
}
5458

5559
for _, tt := range deleteCases {
@@ -63,6 +67,12 @@ func TestDefaultAsyncDelete(t *testing.T) {
6367
require.NoError(t, err)
6468
req.Header.Set("If-Match", tt.etag)
6569

70+
if tt.force {
71+
q := req.URL.Query()
72+
q.Set("force", "true")
73+
req.URL.RawQuery = q.Encode()
74+
}
75+
6676
ctx := rpctest.NewARMRequestContext(req)
6777
_, appDataModel, _ := loadTestResurce()
6878

@@ -81,7 +91,7 @@ func TestDefaultAsyncDelete(t *testing.T) {
8191
}, tt.getErr).
8292
Times(1)
8393

84-
if tt.getErr == nil && !tt.rejectedByFilter && appDataModel.InternalMetadata.AsyncProvisioningState.IsTerminal() {
94+
if tt.getErr == nil && !tt.rejectedByFilter && (appDataModel.InternalMetadata.AsyncProvisioningState.IsTerminal() || tt.force) {
8595
expectedOptions := statusmanager.QueueOperationOptions{
8696
OperationTimeout: asyncOperationTimeout,
8797
RetryAfter: asyncOperationRetryAfter,

pkg/cli/clients/clients.go

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,8 @@ type DeleteOptions struct {
7070
ProgressText string
7171
// ProgressChan is a channel used to signal progress of the deletion operation.
7272
ProgressChan chan<- ResourceProgress
73+
// Force indicates whether to force delete resources that are in a non-terminal provisioning state.
74+
Force bool
7375
}
7476

7577
type ResourceStatus string
@@ -177,7 +179,8 @@ type ApplicationsManagementClient interface {
177179
CreateOrUpdateResource(ctx context.Context, resourceType string, resourceNameOrID string, resource *generated.GenericResource) (generated.GenericResource, error)
178180

179181
// DeleteResource deletes a resource by its type and name (or id).
180-
DeleteResource(ctx context.Context, resourceType string, resourceNameOrID string) (bool, error)
182+
// When force is true, the delete will proceed even if the resource is in a non-terminal provisioning state.
183+
DeleteResource(ctx context.Context, resourceType string, resourceNameOrID string, force bool) (bool, error)
181184

182185
// ListApplications lists all applications in the configured scope.
183186
ListApplications(ctx context.Context) ([]corerp.ApplicationResource, error)
@@ -195,7 +198,8 @@ type ApplicationsManagementClient interface {
195198
CreateApplicationIfNotFound(ctx context.Context, applicationNameOrID string, resource *corerp.ApplicationResource) error
196199

197200
// DeleteApplication deletes an application and all of its resources by its name (or id).
198-
DeleteApplication(ctx context.Context, applicationNameOrID string) (bool, error)
201+
// When force is true, resources in non-terminal provisioning states will be force-deleted.
202+
DeleteApplication(ctx context.Context, applicationNameOrID string, force bool) (bool, error)
199203

200204
// ListEnvironments lists all environments in the configured scope (assumes configured scope is a resource group).
201205
ListEnvironments(ctx context.Context) ([]corerp.EnvironmentResource, error)

pkg/cli/clients/management.go

Lines changed: 47 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -178,7 +178,7 @@ func (amc *UCPApplicationsManagementClient) CreateOrUpdateResource(ctx context.C
178178
}
179179

180180
// DeleteResource deletes a resource by its type and name (or id).
181-
func (amc *UCPApplicationsManagementClient) DeleteResource(ctx context.Context, resourceType string, resourceNameOrID string) (bool, error) {
181+
func (amc *UCPApplicationsManagementClient) DeleteResource(ctx context.Context, resourceType string, resourceNameOrID string, force bool) (bool, error) {
182182
apiVersions, err := amc.getApiVersionsForResourceType(ctx, resourceType)
183183
if err != nil {
184184
return false, err
@@ -189,7 +189,12 @@ func (amc *UCPApplicationsManagementClient) DeleteResource(ctx context.Context,
189189
return false, err
190190
}
191191

192-
client, err := amc.getGenericClient(scope, resourceType, apiVersions)
192+
var client genericResourceClient
193+
if force {
194+
client, err = amc.getGenericClientWithForce(scope, resourceType, apiVersions)
195+
} else {
196+
client, err = amc.getGenericClient(scope, resourceType, apiVersions)
197+
}
193198
if err != nil {
194199
return false, err
195200
}
@@ -357,7 +362,7 @@ func (amc *UCPApplicationsManagementClient) CreateApplicationIfNotFound(ctx cont
357362
}
358363

359364
// DeleteApplication deletes an application and all of its resources by its name (or id).
360-
func (amc *UCPApplicationsManagementClient) DeleteApplication(ctx context.Context, applicationNameOrID string) (bool, error) {
365+
func (amc *UCPApplicationsManagementClient) DeleteApplication(ctx context.Context, applicationNameOrID string, force bool) (bool, error) {
361366
scope, name, err := amc.extractScopeAndName(applicationNameOrID)
362367
if err != nil {
363368
return false, err
@@ -373,7 +378,7 @@ func (amc *UCPApplicationsManagementClient) DeleteApplication(ctx context.Contex
373378
g, groupCtx := errgroup.WithContext(ctx)
374379
for _, resource := range resources {
375380
g.Go(func() error {
376-
_, err := amc.DeleteResource(groupCtx, *resource.Type, *resource.ID)
381+
_, err := amc.DeleteResource(groupCtx, *resource.Type, *resource.ID, force)
377382
if err != nil && !clientv2.Is404Error(err) {
378383
return err
379384
}
@@ -641,7 +646,7 @@ func (amc *UCPApplicationsManagementClient) DeleteEnvironment(ctx context.Contex
641646
}
642647

643648
for _, application := range applications {
644-
_, err := amc.DeleteApplication(ctx, *application.ID)
649+
_, err := amc.DeleteApplication(ctx, *application.ID, false)
645650
if err != nil {
646651
return false, err
647652
}
@@ -748,7 +753,7 @@ func (amc *UCPApplicationsManagementClient) DeleteResourceGroup(ctx context.Cont
748753
for _, resource := range resources {
749754
g.Go(func() error {
750755
// Delete each resource using its full ID to ensure correct scope
751-
_, err := amc.DeleteResource(groupCtx, *resource.Type, *resource.ID)
756+
_, err := amc.DeleteResource(groupCtx, *resource.Type, *resource.ID, false)
752757
if err != nil && !clientv2.Is404Error(err) {
753758
return err
754759
}
@@ -1397,3 +1402,39 @@ func (amc *UCPApplicationsManagementClient) getGenericClient(scope, resourceType
13971402

13981403
return client, err
13991404
}
1405+
1406+
// getGenericClientWithForce creates a generic client with a per-call policy that appends
1407+
// the force=true query parameter to the request URL. This is used for force-deleting
1408+
// resources that are in a non-terminal provisioning state.
1409+
func (amc *UCPApplicationsManagementClient) getGenericClientWithForce(scope, resourceType string, apiVersions []string) (client genericResourceClient, err error) {
1410+
if amc.genericResourceClientFactory != nil {
1411+
return amc.genericResourceClientFactory(scope, resourceType)
1412+
}
1413+
1414+
clientOptions := *amc.ClientOptions
1415+
clientOptions.PerCallPolicies = append(
1416+
append([]policy.Policy{}, clientOptions.PerCallPolicies...),
1417+
&forceDeletePolicy{},
1418+
)
1419+
1420+
if strings.HasPrefix(resourceType, "Radius.Core") {
1421+
apiVersions = []string{"2025-08-01-preview"}
1422+
}
1423+
1424+
if len(apiVersions) != 0 {
1425+
clientOptions.APIVersion = apiVersions[0]
1426+
}
1427+
1428+
return generated.NewGenericResourcesClient(resourceType, strings.TrimPrefix(scope, resources.SegmentSeparator), &aztoken.AnonymousCredential{}, &clientOptions)
1429+
}
1430+
1431+
// forceDeletePolicy is a per-call pipeline policy that appends force=true to the request URL query string.
1432+
type forceDeletePolicy struct{}
1433+
1434+
func (p *forceDeletePolicy) Do(req *policy.Request) (*http.Response, error) {
1435+
rawReq := req.Raw()
1436+
q := rawReq.URL.Query()
1437+
q.Set("force", "true")
1438+
rawReq.URL.RawQuery = q.Encode()
1439+
return req.Next()
1440+
}

pkg/cli/clients/management_test.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -662,7 +662,7 @@ func Test_Resource(t *testing.T) {
662662
BeginDelete(gomock.Any(), testResourceName, gomock.Any()).
663663
Return(poller(&generated.GenericResourcesClientDeleteResponse{}), nil)
664664

665-
deleted, err := client.DeleteResource(context.Background(), testResourceType, testResourceID)
665+
deleted, err := client.DeleteResource(context.Background(), testResourceType, testResourceID, false)
666666
require.NoError(t, err)
667667
require.True(t, deleted)
668668
})
@@ -968,7 +968,7 @@ func Test_Application(t *testing.T) {
968968
return corerp.ApplicationsClientDeleteResponse{}, nil
969969
})
970970

971-
deleted, err := client.DeleteApplication(context.Background(), testResourceID)
971+
deleted, err := client.DeleteApplication(context.Background(), testResourceID, false)
972972
require.NoError(t, err)
973973
require.True(t, deleted)
974974
})
@@ -1048,7 +1048,7 @@ func Test_Application(t *testing.T) {
10481048
return corerp.ApplicationsClientDeleteResponse{}, nil
10491049
})
10501050

1051-
deleted, err := client.DeleteApplication(context.Background(), testResourceID)
1051+
deleted, err := client.DeleteApplication(context.Background(), testResourceID, false)
10521052
require.NoError(t, err)
10531053
require.True(t, deleted)
10541054
})
@@ -1087,7 +1087,7 @@ func Test_Application(t *testing.T) {
10871087
// Delete should NOT be called when ListResourcesInApplication fails with non-404 error
10881088
// No expectation set for mock.Delete()
10891089

1090-
deleted, err := client.DeleteApplication(context.Background(), testResourceID)
1090+
deleted, err := client.DeleteApplication(context.Background(), testResourceID, false)
10911091
require.Error(t, err)
10921092
require.False(t, deleted)
10931093
// Verify the error is propagated correctly

pkg/cli/clients/mock_applicationsclient.go

Lines changed: 12 additions & 12 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pkg/cli/cmd/app/delete/delete.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,9 @@ rad app delete my-app
6565
6666
# Delete specified application in a specified resource group
6767
rad app delete my-app --group my-group
68+
69+
# Force delete an application with resources stuck in a non-terminal state
70+
rad app delete my-app --force
6871
`,
6972
Args: cobra.MaximumNArgs(1),
7073
RunE: framework.RunCommand(runner),
@@ -74,6 +77,7 @@ rad app delete my-app --group my-group
7477
commonflags.AddResourceGroupFlag(cmd)
7578
commonflags.AddApplicationNameFlag(cmd)
7679
commonflags.AddConfirmationFlag(cmd)
80+
commonflags.AddForceFlag(cmd)
7781

7882
return cmd, runner
7983
}
@@ -90,6 +94,7 @@ type Runner struct {
9094
EnvironmentName string
9195
Scope string
9296
Confirm bool
97+
Force bool
9398
Workspace *workspaces.Workspace
9499
}
95100

@@ -148,6 +153,11 @@ func (r *Runner) Validate(cmd *cobra.Command, args []string) error {
148153
return err
149154
}
150155

156+
r.Force, err = cmd.Flags().GetBool("force")
157+
if err != nil {
158+
return err
159+
}
160+
151161
return nil
152162
}
153163

@@ -191,9 +201,14 @@ func (r *Runner) Run(ctx context.Context) error {
191201

192202
progressText := fmt.Sprintf("Deleting application '%s' from environment '%s'...", r.ApplicationName, r.EnvironmentName)
193203

204+
if r.Force {
205+
r.Output.LogInfo("WARNING: Force deleting an application. Resources in non-terminal states may leave orphaned external resources that require manual cleanup.")
206+
}
207+
194208
deleted, err := r.Delete.DeleteApplicationWithProgress(ctx, client, clients.DeleteOptions{
195209
ApplicationNameOrID: r.ApplicationName,
196210
ProgressText: progressText,
211+
Force: r.Force,
197212
})
198213
if err != nil {
199214
if strings.Contains(err.Error(), "not found") {

pkg/cli/cmd/commonflags/flags.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,11 @@ func AddConfirmationFlag(cmd *cobra.Command) {
7070
cmd.Flags().BoolP("yes", "y", false, "The confirmation flag")
7171
}
7272

73+
// AddForceFlag adds a flag to the given command that allows the user to force an operation even when the resource is in a non-terminal state.
74+
func AddForceFlag(cmd *cobra.Command) {
75+
cmd.Flags().Bool("force", false, "Force the operation even if the resource is in a non-terminal provisioning state")
76+
}
77+
7378
// AddEnvironmentNameFlag adds a flag to the given command that allows the user to specify an environment name.
7479
func AddEnvironmentNameFlag(cmd *cobra.Command) {
7580
cmd.Flags().StringP("environment", "e", "", "The environment name")

0 commit comments

Comments
 (0)