Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions pkg/armrpc/frontend/defaultoperation/defaultasyncdelete.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ func NewDefaultAsyncDelete[P interface {
}

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

if r, err := e.PrepareResource(ctx, req, nil, old, etag); r != nil || err != nil {
return r, err
force := req.URL.Query().Get("force") == "true"
if force {
// When force-deleting, skip the provisioning state check but still validate the ETag.
if err := ctrl.ValidateETag(*serviceCtx, etag); err != nil {
return rest.NewPreconditionFailedResponse(serviceCtx.ResourceID.String(), err.Error()), nil
}
} else {
if r, err := e.PrepareResource(ctx, req, nil, old, etag); r != nil || err != nil {
return r, err
}
}

for _, filter := range e.DeleteFilters() {
Expand Down
21 changes: 16 additions & 5 deletions pkg/armrpc/frontend/defaultoperation/defaultasyncdelete_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,12 +44,17 @@ func TestDefaultAsyncDelete(t *testing.T) {
qErr error
saveErr error
rejectedByFilter bool
force bool
code int
}{
{"async-delete-non-existing-resource-no-etag", "", v1.ProvisioningStateNone, &database.ErrNotFound{}, nil, nil, false, http.StatusNoContent},
{"async-delete-existing-resource-blocked-by-filter", "", v1.ProvisioningStateSucceeded, nil, nil, nil, true, http.StatusConflict},
{"async-delete-existing-resource-not-in-terminal-state", "", v1.ProvisioningStateUpdating, nil, nil, nil, false, http.StatusConflict},
{"async-delete-existing-resource-success", "", v1.ProvisioningStateSucceeded, nil, nil, nil, false, http.StatusAccepted},
{"async-delete-non-existing-resource-no-etag", "", v1.ProvisioningStateNone, &database.ErrNotFound{}, nil, nil, false, false, http.StatusNoContent},
{"async-delete-existing-resource-blocked-by-filter", "", v1.ProvisioningStateSucceeded, nil, nil, nil, true, false, http.StatusConflict},
{"async-delete-existing-resource-not-in-terminal-state", "", v1.ProvisioningStateUpdating, nil, nil, nil, false, false, http.StatusConflict},
{"async-delete-existing-resource-success", "", v1.ProvisioningStateSucceeded, nil, nil, nil, false, false, http.StatusAccepted},
{"async-force-delete-existing-resource-in-updating-state", "", v1.ProvisioningStateUpdating, nil, nil, nil, false, true, http.StatusAccepted},
{"async-force-delete-existing-resource-in-accepted-state", "", v1.ProvisioningStateAccepted, nil, nil, nil, false, true, http.StatusAccepted},
Comment thread
willdavsmith marked this conversation as resolved.
{"async-force-delete-existing-resource-bad-etag", "\"incorrect-etag\"", v1.ProvisioningStateUpdating, nil, nil, nil, false, true, http.StatusPreconditionFailed},
{"async-force-delete-existing-resource-blocked-by-filter", "", v1.ProvisioningStateUpdating, nil, nil, nil, true, true, http.StatusConflict},
}

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

if tt.force {
q := req.URL.Query()
q.Set("force", "true")
req.URL.RawQuery = q.Encode()
}

ctx := rpctest.NewARMRequestContext(req)
_, appDataModel, _ := loadTestResurce()

Expand All @@ -81,7 +92,7 @@ func TestDefaultAsyncDelete(t *testing.T) {
}, tt.getErr).
Times(1)

if tt.getErr == nil && !tt.rejectedByFilter && appDataModel.InternalMetadata.AsyncProvisioningState.IsTerminal() {
if tt.getErr == nil && !tt.rejectedByFilter && tt.code != http.StatusPreconditionFailed && (appDataModel.InternalMetadata.AsyncProvisioningState.IsTerminal() || tt.force) {
expectedOptions := statusmanager.QueueOperationOptions{
OperationTimeout: asyncOperationTimeout,
RetryAfter: asyncOperationRetryAfter,
Expand Down
8 changes: 6 additions & 2 deletions pkg/cli/clients/clients.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,8 @@ type DeleteOptions struct {
ProgressText string
// ProgressChan is a channel used to signal progress of the deletion operation.
ProgressChan chan<- ResourceProgress
// Force indicates whether to force delete resources that are in a non-terminal provisioning state.
Force bool
}

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

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

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

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

// ListEnvironments lists all environments in the configured scope (assumes configured scope is a resource group).
ListEnvironments(ctx context.Context) ([]corerp.EnvironmentResource, error)
Expand Down
99 changes: 74 additions & 25 deletions pkg/cli/clients/management.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ func (amc *UCPApplicationsManagementClient) ListResourcesOfType(ctx context.Cont
}
results := []generated.GenericResource{}

client, err := amc.getGenericClient(amc.RootScope, resourceType, apiVersions)
client, err := amc.getGenericClient(amc.RootScope, resourceType, apiVersions, false)
if err != nil {
return nil, err
}
Expand Down Expand Up @@ -139,7 +139,7 @@ func (amc *UCPApplicationsManagementClient) GetResource(ctx context.Context, res
return generated.GenericResource{}, err
}

client, err := amc.getGenericClient(scope, resourceType, apiVersions)
client, err := amc.getGenericClient(scope, resourceType, apiVersions, false)
if err != nil {
return generated.GenericResource{}, err
}
Expand Down Expand Up @@ -178,7 +178,7 @@ func (amc *UCPApplicationsManagementClient) CreateOrUpdateResource(ctx context.C
}

// DeleteResource deletes a resource by its type and name (or id).
func (amc *UCPApplicationsManagementClient) DeleteResource(ctx context.Context, resourceType string, resourceNameOrID string) (bool, error) {
func (amc *UCPApplicationsManagementClient) DeleteResource(ctx context.Context, resourceType string, resourceNameOrID string, force bool) (bool, error) {
apiVersions, err := amc.getApiVersionsForResourceType(ctx, resourceType)
if err != nil {
return false, err
Expand All @@ -189,7 +189,8 @@ func (amc *UCPApplicationsManagementClient) DeleteResource(ctx context.Context,
return false, err
}

client, err := amc.getGenericClient(scope, resourceType, apiVersions)
var client genericResourceClient
client, err = amc.getGenericClient(scope, resourceType, apiVersions, force)
if err != nil {
return false, err
}
Expand Down Expand Up @@ -357,7 +358,7 @@ func (amc *UCPApplicationsManagementClient) CreateApplicationIfNotFound(ctx cont
}

// DeleteApplication deletes an application and all of its resources by its name (or id).
func (amc *UCPApplicationsManagementClient) DeleteApplication(ctx context.Context, applicationNameOrID string) (bool, error) {
func (amc *UCPApplicationsManagementClient) DeleteApplication(ctx context.Context, applicationNameOrID string, force bool) (bool, error) {
scope, name, err := amc.extractScopeAndName(applicationNameOrID)
if err != nil {
return false, err
Expand All @@ -373,7 +374,7 @@ func (amc *UCPApplicationsManagementClient) DeleteApplication(ctx context.Contex
g, groupCtx := errgroup.WithContext(ctx)
for _, resource := range resources {
g.Go(func() error {
_, err := amc.DeleteResource(groupCtx, *resource.Type, *resource.ID)
_, err := amc.DeleteResource(groupCtx, *resource.Type, *resource.ID, force)
if err != nil && !clientv2.Is404Error(err) {
return err
Comment thread
willdavsmith marked this conversation as resolved.
}
Expand All @@ -387,7 +388,8 @@ func (amc *UCPApplicationsManagementClient) DeleteApplication(ctx context.Contex
return false, err
}

client, err := amc.createApplicationClient(scope)
var client applicationResourceClient
client, err = amc.createApplicationClient(scope, force)
if err != nil {
return false, err
}
Expand Down Expand Up @@ -641,7 +643,7 @@ func (amc *UCPApplicationsManagementClient) DeleteEnvironment(ctx context.Contex
}

for _, application := range applications {
_, err := amc.DeleteApplication(ctx, *application.ID)
_, err := amc.DeleteApplication(ctx, *application.ID, false)
if err != nil {
return false, err
}
Expand Down Expand Up @@ -748,7 +750,7 @@ func (amc *UCPApplicationsManagementClient) DeleteResourceGroup(ctx context.Cont
for _, resource := range resources {
g.Go(func() error {
// Delete each resource using its full ID to ensure correct scope
_, err := amc.DeleteResource(groupCtx, *resource.Type, *resource.ID)
_, err := amc.DeleteResource(groupCtx, *resource.Type, *resource.ID, false)
if err != nil && !clientv2.Is404Error(err) {
return err
}
Expand Down Expand Up @@ -804,7 +806,7 @@ func (amc *UCPApplicationsManagementClient) ListResourcesInResourceGroup(ctx con
continue // Skip this resource type if we can't get API versions
}

client, err := amc.getGenericClient(groupScope, resourceType, apiVersions)
client, err := amc.getGenericClient(groupScope, resourceType, apiVersions, false)
if err != nil {
continue
}
Expand Down Expand Up @@ -869,7 +871,7 @@ func (amc *UCPApplicationsManagementClient) ListResourcesOfTypeInResourceGroup(c
return nil, err
}

client, err := amc.getGenericClient(groupScope, resourceType, apiVersions)
client, err := amc.getGenericClient(groupScope, resourceType, apiVersions, false)
if err != nil {
return nil, err
}
Expand Down Expand Up @@ -1198,13 +1200,23 @@ func (amc *UCPApplicationsManagementClient) CreateOrUpdateLocation(ctx context.C
return response.LocationResource, nil
}

func (amc *UCPApplicationsManagementClient) createApplicationClient(scope string) (applicationResourceClient, error) {
if amc.applicationResourceClientFactory == nil {
// Generated client doesn't like the leading '/' in the scope.
return corerpv20231001.NewApplicationsClient(strings.TrimPrefix(scope, resources.SegmentSeparator), &aztoken.AnonymousCredential{}, amc.ClientOptions)
// createApplicationClient creates an application resource client for the specified scope.
// When no applicationResourceClientFactory is configured and force is true, a per-call policy is added
// that appends force=true to the request URL query string.
func (amc *UCPApplicationsManagementClient) createApplicationClient(scope string, force ...bool) (applicationResourceClient, error) {
if amc.applicationResourceClientFactory != nil {
return amc.applicationResourceClientFactory(scope)
}

return amc.applicationResourceClientFactory(scope)
forceEnabled := len(force) > 0 && force[0]
clientOptions := amc.ClientOptions
if forceEnabled {
opts := withForceDeletePolicy(*amc.ClientOptions)
clientOptions = &opts
}

// Generated client doesn't like the leading '/' in the scope.
return corerpv20231001.NewApplicationsClient(strings.TrimPrefix(scope, resources.SegmentSeparator), &aztoken.AnonymousCredential{}, clientOptions)
}

func (amc *UCPApplicationsManagementClient) createRecipePackClient(scope string) (recipePackResourceClient, error) {
Expand Down Expand Up @@ -1377,23 +1389,60 @@ func (amc *UCPApplicationsManagementClient) getApiVersionsForResourceType(ctx co
}

// getGenericClient returns a generic resource client for the specified scope and resource type.
// if apiVersions is empty, it uses the default version i.e 2023-10-01-preview else uses any version supported by the resource type.
func (amc *UCPApplicationsManagementClient) getGenericClient(scope, resourceType string, apiVersions []string) (client genericResourceClient, err error) {
// If apiVersions is empty, it uses the default version (2023-10-01-preview), else uses any version supported by the resource type.
// When no genericResourceClientFactory is configured and force is true, a per-call policy is added
// that appends force=true to the request URL query string. This is used for force-deleting resources
// that are in a non-terminal provisioning state. Factory-based configurations (used in tests) bypass
// the force policy since mock clients do not exercise the HTTP pipeline.
func (amc *UCPApplicationsManagementClient) getGenericClient(scope, resourceType string, apiVersions []string, force bool) (client genericResourceClient, err error) {
// Radius.Core resources require a specific API version.
// Eventually version 2023-10-01-preview will be removed along with Applications.Core resources.
// Then we will not need this special case.
if strings.HasPrefix(resourceType, "Radius.Core") {
apiVersions = []string{"2025-08-01-preview"}
}
if len(apiVersions) == 0 {
client, err = amc.createGenericClient(scope, resourceType)
} else {
client, err = amc.createGenericClient(scope, resourceType, apiVersions[0])

if amc.genericResourceClientFactory != nil {
return amc.genericResourceClientFactory(scope, resourceType)
}
Comment thread
willdavsmith marked this conversation as resolved.

if err != nil {
return nil, err
clientOptions := *amc.ClientOptions
Copy link
Copy Markdown
Contributor

@lakshmimsft lakshmimsft Apr 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

are these lines of code doing the same as ~ln1211-ln1220? should it be pulled out into a helper funciton?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — they were doing the same thing. Extracted into a withForceDeletePolicy helper in bb897a6 so both createApplicationClient and getGenericClient share the same logic for appending the per-call policy.


if force {
clientOptions = withForceDeletePolicy(clientOptions)
}

if len(apiVersions) != 0 {
clientOptions.APIVersion = apiVersions[0]
}

return generated.NewGenericResourcesClient(resourceType, strings.TrimPrefix(scope, resources.SegmentSeparator), &aztoken.AnonymousCredential{}, &clientOptions)
}

// withForceDeletePolicy returns a copy of opts with forceDeletePolicy appended to PerCallPolicies.
func withForceDeletePolicy(opts arm.ClientOptions) arm.ClientOptions {
opts.PerCallPolicies = append(
append([]policy.Policy{}, opts.PerCallPolicies...),
&forceDeletePolicy{},
)
return opts
}

// forceDeletePolicy is a per-call pipeline policy that appends force=true to DELETE request URL query strings.
type forceDeletePolicy struct{}

func (p *forceDeletePolicy) Do(req *policy.Request) (*http.Response, error) {
rawReq := req.Raw()
if rawReq.Method != http.MethodDelete {
return req.Next()
}

q := rawReq.URL.Query()
if _, ok := q["force"]; ok {
return req.Next()
}

return client, err
q.Set("force", "true")
rawReq.URL.RawQuery = q.Encode()
return req.Next()
}
Loading
Loading