Skip to content
Merged
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
6 changes: 6 additions & 0 deletions docs/architecture/extensibility.md
Original file line number Diff line number Diff line change
Expand Up @@ -421,6 +421,12 @@ recipe output against the full registered schema;
[pkg/dynamicrp/backend/processor/dynamicresource.go](../../pkg/dynamicrp/backend/processor/dynamicresource.go)
contains a TODO noting that schema-driven output validation is bypassed.

### Bicep Recipe Resource Deletion

The Bicep driver deletes tracked recipe resources on resource deletion and removes obsolete resources after an update. For Azure Key Vault, ARM does not support direct deletion of `Microsoft.KeyVault/vaults/secrets`. When the same deletion batch contains both a secret and its Radius-managed parent vault, the driver deletes the vault instead of sending a separate secret DELETE. The batch waits for vault deletion to finish and reports a failure if vault deletion fails. Azure soft-delete retention still applies; this operation does not purge the vault or its secrets.

The parent match uses ARM-form paths, so a plain ARM ID can match an Azure UCP-qualified ID for the same resource. Only Azure plane prefixes are removed; subscription and resource group scopes remain part of the match. This rule does not apply if the vault remains in the current recipe output, is unmanaged, or is absent from the deletion batch. It does not ignore HTTP 405 errors, change secret values, or transfer resource ownership. Removing a tracked secret while retaining its vault still uses the existing direct deletion path.

### Recipe Secret Outputs

Recipe **secret** outputs (`RecipeOutput.Secrets`) are handled separately from
Expand Down
36 changes: 36 additions & 0 deletions pkg/recipes/driver/bicep/bicep.go
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,12 @@ func (d *bicepDriver) Delete(ctx context.Context, opts driver.DeleteOptions) err
for i := range opts.OutputResources {
outputResource := opts.OutputResources[i]

if outputResource.IsRadiusManaged() && deletingKeyVaultParent(outputResource.ID, opts.OutputResources) {
Comment thread
nicolejms marked this conversation as resolved.
// ARM cannot delete secrets. The batch must still wait for the managed vault deletion to succeed.
logger.Info("Skipping direct Key Vault secret deletion; the managed parent vault is in the deletion batch", "resourceID", outputResource.ID.String())
continue
}

// Create a goroutine that handles the deletion of one resource
g.Go(func() error {
id := outputResource.ID.String()
Expand Down Expand Up @@ -304,6 +310,36 @@ func (d *bicepDriver) Delete(ctx context.Context, opts driver.DeleteOptions) err
return nil
}

func deletingKeyVaultParent(id resources.ID, outputResources []rpv1.OutputResource) bool {
if !strings.EqualFold(id.Type(), "Microsoft.KeyVault/vaults/secrets") || len(id.ExtensionSegments()) != 0 {
return false
}
if id.IsUCPQualified() && !strings.HasPrefix(strings.ToLower(id.PlaneNamespace()), "azure/") {
return false
}

parentID := azureResourcePath(id.Truncate())
for _, output := range outputResources {
if output.IsRadiusManaged() && strings.EqualFold(azureResourcePath(output.ID), parentID) {
return true
}
}

return false
}

func azureResourcePath(id resources.ID) string {
if !id.IsUCPQualified() {
return id.String()
}
if !strings.HasPrefix(strings.ToLower(id.PlaneNamespace()), "azure/") {
return ""
}

// Match the ARM path used by resourceClient.deleteAzureResource, not an unrelated plane's path.
return resources.MakeRelativeID(id.ScopeSegments()[1:], id.TypeSegments(), id.ExtensionSegments())
}

func (d *bicepDriver) resourceClientForDelete(ctx context.Context, opts driver.DeleteOptions) (processors.ResourceClient, error) {
hasManagedOutput := false
hasManagedKubernetesOutput := false
Expand Down
219 changes: 219 additions & 0 deletions pkg/recipes/driver/bicep/keyvault_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
/*
Copyright 2026 The Radius Authors.

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

package bicep

import (
"context"
"errors"
"strings"
"testing"

"github.com/radius-project/radius/pkg/recipes/driver"
rpv1 "github.com/radius-project/radius/pkg/rp/v1"
"github.com/radius-project/radius/pkg/ucp/resources"
"github.com/stretchr/testify/require"
"go.uber.org/mock/gomock"
)

func Test_Bicep_Delete_KeyVaultSecrets(t *testing.T) {
const vaultID = "/subscriptions/test-sub/resourceGroups/test-rg/providers/Microsoft.KeyVault/vaults/old-vault"
tests := []struct {
name string
vaultID string
childID string
managedVault *bool
cascade bool
vaultError error
}{
{
name: "managed vault deletes its secrets",
vaultID: vaultID, childID: vaultID + "/secrets/password",
managedVault: new(true), cascade: true,
},
{
name: "Azure qualified IDs",
vaultID: "/planes/azure/local" + vaultID, childID: "/planes/azure/local" + vaultID + "/secrets/password",
managedVault: new(true), cascade: true,
},
{
name: "Azure qualified secret and plain ARM vault",
vaultID: vaultID, childID: "/planes/azure/local" + vaultID + "/secrets/password",
managedVault: new(true), cascade: true,
},
{
name: "plain ARM secret and Azure qualified vault",
vaultID: "/planes/azure/local" + vaultID, childID: vaultID + "/secrets/password",
managedVault: new(true), cascade: true,
},
{
name: "mixed qualification still requires managed vault",
vaultID: "/planes/azure/local" + vaultID, childID: vaultID + "/secrets/password",
managedVault: new(false),
},
{
name: "mixed qualification keeps subscription scope",
vaultID: "/planes/azure/local" + strings.Replace(vaultID, "test-sub", "other-sub", 1),
childID: vaultID + "/secrets/password", managedVault: new(true),
},
{
name: "mixed qualification keeps resource group scope",
vaultID: vaultID,
childID: "/planes/azure/local" + strings.Replace(vaultID, "test-rg", "other-rg", 1) + "/secrets/password",
managedVault: new(true),
},
{
name: "plain ARM secret cannot use unrelated plane vault",
vaultID: "/planes/radius/local" + vaultID, childID: vaultID + "/secrets/password",
managedVault: new(true),
},
{
name: "Azure qualified secret cannot use unrelated plane vault",
vaultID: "/planes/radius/local" + vaultID, childID: "/planes/azure/local" + vaultID + "/secrets/password",
managedVault: new(true),
},
{
name: "unrelated plane secret cannot use plain ARM vault",
vaultID: vaultID, childID: "/planes/radius/local" + vaultID + "/secrets/password",
managedVault: new(true),
},
{
name: "Azure ID matching is case insensitive",
vaultID: strings.ToUpper(vaultID), childID: vaultID + "/secrets/password",
managedVault: new(true), cascade: true,
},
{
name: "parent failure is not success",
vaultID: vaultID, childID: vaultID + "/secrets/password",
managedVault: new(true), cascade: true, vaultError: errors.New("vault deletion denied"),
},
{
name: "unmanaged parent cannot delete secrets",
vaultID: vaultID, childID: vaultID + "/secrets/password",
managedVault: new(false),
},
{
name: "parent with no ownership cannot delete secrets",
vaultID: vaultID, childID: vaultID + "/secrets/password",
},
{
name: "other vault does not delete secrets",
vaultID: vaultID + "-other", childID: vaultID + "/secrets/password",
managedVault: new(true),
},
{
name: "other resource group does not delete secrets",
vaultID: strings.Replace(vaultID, "test-rg", "other-rg", 1), childID: vaultID + "/secrets/password",
managedVault: new(true),
},
{
name: "other subscription does not delete secrets",
vaultID: strings.Replace(vaultID, "test-sub", "other-sub", 1), childID: vaultID + "/secrets/password",
managedVault: new(true),
},
{
name: "non-Azure plane cannot cascade",
vaultID: "/planes/radius/local/resourceGroups/test-rg/providers/Microsoft.KeyVault/vaults/vault",
childID: "/planes/radius/local/resourceGroups/test-rg/providers/Microsoft.KeyVault/vaults/vault/secrets/password",
managedVault: new(true),
},
{
name: "other child types keep their deletion",
vaultID: vaultID, childID: vaultID + "/keys/key",
managedVault: new(true),
},
{
name: "extension resource is not a vault secret",
vaultID: vaultID, childID: vaultID + "/providers/Microsoft.KeyVault/vaults/extension/secrets/password",
managedVault: new(true),
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
d, client, _, _ := setupDeleteInputs(t)
d.options.DeleteRetryDelaySeconds = 0
if tt.managedVault != nil && *tt.managedVault {
client.EXPECT().Delete(gomock.Any(), resources.MustParse(tt.vaultID).String()).Return(tt.vaultError)
}
if !tt.cascade {
client.EXPECT().Delete(gomock.Any(), tt.childID).Return(nil)
}

err := d.Delete(t.Context(), driver.DeleteOptions{
OutputResources: []rpv1.OutputResource{
{ID: resources.MustParse(tt.childID), RadiusManaged: new(true)},
{ID: resources.MustParse(tt.vaultID), RadiusManaged: tt.managedVault},
},
})
if tt.vaultError != nil {
require.ErrorContains(t, err, tt.vaultError.Error())
return
}
require.NoError(t, err)
})
}
}

func Test_Bicep_Delete_KeyVaultDeclarativeInventoryWaitsForVault(t *testing.T) {
const vaultID = "/subscriptions/test-sub/resourceGroups/test-rg/providers/Microsoft.KeyVault/vaults/test-vault"
const identityID = "/subscriptions/test-sub/resourceGroups/test-rg/providers/Microsoft.ManagedIdentity/userAssignedIdentities/secrets"
d, client, _, _ := setupDeleteInputs(t)
outputs, err := d.getGCOutputResources(nil, []string{
vaultID + "/secrets/username",
vaultID + "/secrets/password",
vaultID,
identityID,
})
require.NoError(t, err)

started := make(chan struct{})
finish := make(chan struct{})
t.Cleanup(func() { close(finish) })
client.EXPECT().Delete(gomock.Any(), vaultID).DoAndReturn(func(context.Context, string) error {
close(started)
<-finish
return nil
})
client.EXPECT().Delete(gomock.Any(), identityID).Return(nil)
done := make(chan error, 1)
go func() {
done <- d.Delete(t.Context(), driver.DeleteOptions{OutputResources: outputs})
}()

<-started
select {
case err := <-done:
t.Fatalf("deletion returned before vault deletion finished: %v", err)
default:
}
finish <- struct{}{}
require.NoError(t, <-done)
}

func Test_Bicep_Delete_KeyVaultRetainedDuringUpdateDoesNotSkipSecret(t *testing.T) {
const vaultID = "/subscriptions/test-sub/resourceGroups/test-rg/providers/Microsoft.KeyVault/vaults/test-vault"
const secretID = vaultID + "/secrets/password"
d, client, _, _ := setupDeleteInputs(t)
d.options.DeleteRetryDelaySeconds = 0
outputs, err := d.getGCOutputResources([]string{vaultID}, []string{secretID, vaultID})
require.NoError(t, err)
client.EXPECT().Delete(gomock.Any(), secretID).Return(errors.New("405 DeleteNotSupported"))

err = d.Delete(t.Context(), driver.DeleteOptions{OutputResources: outputs})
require.ErrorContains(t, err, "405 DeleteNotSupported")
}
Loading