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
16 changes: 12 additions & 4 deletions src/Aspire.Hosting.Azure.KeyVault/AzureKeyVaultResource.cs
Original file line number Diff line number Diff line change
Expand Up @@ -64,18 +64,26 @@ public override ProvisionableResource AddAsExistingResource(AzureResourceInfrast
{
var bicepIdentifier = this.GetBicepIdentifier();
var resources = infra.GetProvisionableResources();

// Check if a KeyVaultService with the same identifier already exists
var existingStore = resources.OfType<KeyVaultService>().SingleOrDefault(store => store.BicepIdentifier == bicepIdentifier);

if (existingStore is not null)
{
return existingStore;
}

// Create and add new resource if it doesn't exist
var store = KeyVaultService.FromExisting(bicepIdentifier);
store.Name = NameOutputReference.AsProvisioningParameter(infra);

if (!TryApplyExistingResourceNameAndScope(
Copy link
Member

Choose a reason for hiding this comment

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

Do we need to do this for more Azure resources? Or is KeyVault the only one with this problem?

Copy link
Member Author

@davidfowl davidfowl Aug 19, 2025

Choose a reason for hiding this comment

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

#10992, will throw copilot at it once this is done.

this,
infra,
store))
{
store.Name = NameOutputReference.AsProvisioningParameter(infra);
}

infra.Add(store);
return store;
}
Expand Down
73 changes: 73 additions & 0 deletions src/Aspire.Hosting.Azure/AzureProvisioningResource.cs
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,79 @@ public static T CreateExistingOrNewProvisionableResource<T>(AzureResourceInfrast
return provisionedResource;
}

/// <summary>
/// Attempts to apply the name and (optionally) the resource group scope for the <see cref="ProvisionableResource"/>
/// from an <see cref="ExistingAzureResourceAnnotation"/> attached to <paramref name="aspireResource"/>.
/// </summary>
/// <param name="aspireResource">The Aspire resource that may have an <see cref="ExistingAzureResourceAnnotation"/>.</param>
/// <param name="infra">The infrastructure used for converting parameters into provisioning expressions.</param>
/// <param name="provisionableResource">The <see cref="ProvisionableResource"/> resource to configure.</param>
/// <returns><see langword="true"/> if an <see cref="ExistingAzureResourceAnnotation"/> was present and applied; otherwise <see langword="false"/>.</returns>
/// <remarks>
/// When the annotation includes a resource group, a synthetic <c>scope</c> property is added to the resource's
/// provisionable properties to correctly scope the existing resource in the generated Bicep.
/// The caller is responsible for setting a generated name when the method returns <see langword="false"/>.
/// </remarks>
public static bool TryApplyExistingResourceNameAndScope(IAzureResource aspireResource, AzureResourceInfrastructure infra, ProvisionableResource provisionableResource)
{
ArgumentNullException.ThrowIfNull(aspireResource);
ArgumentNullException.ThrowIfNull(infra);
ArgumentNullException.ThrowIfNull(provisionableResource);

if (!aspireResource.TryGetLastAnnotation<ExistingAzureResourceAnnotation>(out var existingAnnotation))
{
return false;
}

var existingResourceName = existingAnnotation.Name switch
{
ParameterResource nameParameter => nameParameter.AsProvisioningParameter(infra),
string s => new BicepValue<string>(s),
_ => throw new NotSupportedException($"Existing resource name type '{existingAnnotation.Name.GetType()}' is not supported.")
};

((IBicepValue)existingResourceName).Self = new BicepValueReference(provisionableResource, "Name", ["name"]);
provisionableResource.ProvisionableProperties["name"] = existingResourceName;

static bool ResourceGroupEquals(object existingResourceGroup, object? infraResourceGroup)
{
// We're in the resource group being created
if (infraResourceGroup is null)
{
return false;
}

// Compare the resource groups only if they are the same type (string or ParameterResource)
if (infraResourceGroup.GetType() == existingResourceGroup.GetType())
Copy link
Member

Choose a reason for hiding this comment

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

Why this constraint? Couldn't we evaluate the value on a ParameterResource and compare it against a string resource group in a different annotation?

Copy link
Member Author

@davidfowl davidfowl Aug 19, 2025

Choose a reason for hiding this comment

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

That doesn't seem like a meaningful comparison.

{
return infraResourceGroup.Equals(existingResourceGroup);
}

return false;
}

// Apply resource group scope if the target infrastructure's resource group is different from the existing annotation's resource group
if (existingAnnotation.ResourceGroup is not null &&
!ResourceGroupEquals(existingAnnotation.ResourceGroup, infra.AspireResource.Scope?.ResourceGroup))
{
BicepValue<string> scope = existingAnnotation.ResourceGroup switch
{
string rgName => new FunctionCallExpression(new IdentifierExpression("resourceGroup"), new StringLiteralExpression(rgName)),
ParameterResource p => new FunctionCallExpression(new IdentifierExpression("resourceGroup"), p.AsProvisioningParameter(infra).Value.Compile()),
_ => throw new NotSupportedException($"Resource group type '{existingAnnotation.ResourceGroup.GetType()}' is not supported.")
};

// HACK: This is a dance we do to set extra properties using Azure.Provisioning
// will be resolved if we ever get https://github.com/Azure/azure-sdk-for-net/issues/47980
var expression = scope.Compile();
var value = new BicepValue<string>(expression);
((IBicepValue)value).Self = new BicepValueReference(provisionableResource, "Scope", ["scope"]);
provisionableResource.ProvisionableProperties["scope"] = value;
}

return true;
}

private void EnsureParametersAlign(AzureResourceInfrastructure infrastructure)
{
// WARNING: GetParameters currently returns more than one instance of the same
Expand Down
13 changes: 10 additions & 3 deletions src/Aspire.Hosting.Azure/AzureProvisioningResourceExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -66,15 +66,22 @@ public static KeyVaultSecret AsKeyVaultSecret(this IAzureKeyVaultSecretReference

var resources = infrastructure.GetProvisionableResources();

var parameter = secretReference.Resource.NameOutputReference.AsProvisioningParameter(infrastructure);
var kvName = Infrastructure.NormalizeBicepIdentifier($"{parameter.BicepIdentifier}_kv");
var kvName = secretReference.Resource.GetBicepIdentifier();

var kv = resources.OfType<KeyVaultService>().SingleOrDefault(kv => kv.BicepIdentifier == kvName);

if (kv is null)
{
kv = KeyVaultService.FromExisting(kvName);
kv.Name = parameter;

if (!AzureProvisioningResource.TryApplyExistingResourceNameAndScope(
secretReference.Resource,
infrastructure,
kv))
{
kv.Name = secretReference.Resource.NameOutputReference.AsProvisioningParameter(infrastructure);
}

infrastructure.Add(kv);
}

Expand Down
9 changes: 8 additions & 1 deletion tests/Aspire.Hosting.Azure.Tests/AzureAppServiceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -81,8 +81,15 @@ public async Task KeyvaultReferenceHandling()
var db = builder.AddAzureCosmosDB("mydb").WithAccessKeyAuthentication();
db.AddCosmosDatabase("db");

var kvName = builder.AddParameter("kvName");
var sharedRg = builder.AddParameter("sharedRg");

var existingKv = builder.AddAzureKeyVault("existingKv")
.PublishAsExisting(kvName, sharedRg);

builder.AddProject<Project>("api", launchProfileName: null)
.WithReference(db);
.WithReference(db)
.WithEnvironment("SECRET_VALUE", existingKv.GetSecret("secret"));

using var app = builder.Build();

Expand Down
13 changes: 11 additions & 2 deletions tests/Aspire.Hosting.Azure.Tests/AzureContainerAppsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -423,10 +423,12 @@ public async Task AzureContainerAppsBicepGenerationIsIdempotent()

var secret = builder.AddParameter("secret", secret: true);
var kv = builder.AddAzureKeyVault("kv");
var existingKv = builder.AddAzureKeyVault("existingKv").PublishAsExisting("existingKvName", "existingRgName");

builder.AddContainer("api", "myimage")
.WithEnvironment("TOP_SECRET", secret)
.WithEnvironment("TOP_SECRET2", kv.Resource.GetSecret("secret"));
.WithEnvironment("TOP_SECRET2", kv.GetSecret("secret"))
.WithEnvironment("EXISTING_TOP_SECRET", existingKv.GetSecret("secret"));

using var app = builder.Build();

Expand Down Expand Up @@ -670,8 +672,15 @@ public async Task KeyVaultReferenceHandling()
var db = builder.AddAzureCosmosDB("mydb").WithAccessKeyAuthentication();
db.AddCosmosDatabase("db");

var kvName = builder.AddParameter("kvName");
var sharedRg = builder.AddParameter("sharedRg");

var existingKv = builder.AddAzureKeyVault("existingKv")
.PublishAsExisting(kvName, sharedRg);

builder.AddContainer("api", "image")
.WithReference(db);
.WithReference(db)
.WithEnvironment("SECRET_VALUE", existingKv.GetSecret("secret"));

using var app = builder.Build();

Expand Down
82 changes: 75 additions & 7 deletions tests/Aspire.Hosting.Azure.Tests/AzureKeyVaultTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ public async Task AddKeyVaultViaRunMode()

await Verify(manifest.ToString(), "json")
.AppendContentAsFile(bicep, "bicep");

}

[Fact]
Expand All @@ -41,7 +41,7 @@ await Verify(manifest.ToString(), "json")
.AppendContentAsFile(bicep, "bicep")
.AppendContentAsFile(kvRolesBicep, "bicep")
.AppendContentAsFile(kvRolesManifest.ToString(), "json");

}

[Fact]
Expand Down Expand Up @@ -111,7 +111,75 @@ public async Task ConsumingAKeyVaultSecretInAnotherBicepModule()

await Verify(manifest.ToString(), "json")
.AppendContentAsFile(bicep, "bicep");


}

[Fact]
public async Task ConsumingSecretsFromExistingKeyVaultInAnotherBicepModule_WithParameters()
{
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);

var existingName = builder.AddParameter("existingKvName");
var existingRg = builder.AddParameter("existingRgName");
var kv = builder.AddAzureKeyVault("kv").PublishAsExisting(existingName, existingRg);

var secretReference = kv.Resource.GetSecret("mySecret");
var secretReference2 = kv.Resource.GetSecret("mySecret2");

var module = builder.AddAzureInfrastructure("mymodule", infra =>
{
var secret = secretReference.AsKeyVaultSecret(infra);
var secret2 = secretReference2.AsKeyVaultSecret(infra);
_ = secretReference.AsKeyVaultSecret(infra); // idempotent

infra.Add(new ProvisioningOutput("secretUri1", typeof(string)) { Value = secret.Properties.SecretUri });
infra.Add(new ProvisioningOutput("secretUri2", typeof(string)) { Value = secret2.Properties.SecretUri });
});

var module2 = builder.AddAzureInfrastructure("mymodule2", infra =>
{
var secret = secretReference.AsKeyVaultSecret(infra);
var secret2 = secretReference2.AsKeyVaultSecret(infra);

infra.Add(new ProvisioningOutput("secretUri1", typeof(string)) { Value = secret.Properties.SecretUri });
infra.Add(new ProvisioningOutput("secretUri2", typeof(string)) { Value = secret2.Properties.SecretUri });
});

module2.Resource.Scope = new(existingRg.Resource);

var (manifest, bicep) = await AzureManifestUtils.GetManifestWithBicep(module.Resource, skipPreparer: true);
var (manifest2, bicep2) = await AzureManifestUtils.GetManifestWithBicep(module2.Resource, skipPreparer: true);

await Verify(manifest.ToString(), "json")
.AppendContentAsFile(bicep, "bicep")
.AppendContentAsFile(manifest.ToString(), "json")
.AppendContentAsFile(bicep2, "bicep");
}

[Fact]
public async Task ConsumingSecretsFromExistingKeyVaultInAnotherBicepModule_WithLiterals()
{
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);

var kv = builder.AddAzureKeyVault("kv").PublishAsExisting("literalKvName", "literalRgName");

var secretReference = kv.Resource.GetSecret("mySecret");
var secretReference2 = kv.Resource.GetSecret("mySecret2");

var module = builder.AddAzureInfrastructure("mymodule", infra =>
{
var secret = secretReference.AsKeyVaultSecret(infra);
var secret2 = secretReference2.AsKeyVaultSecret(infra);
_ = secretReference.AsKeyVaultSecret(infra); // idempotent

infra.Add(new ProvisioningOutput("secretUri1", typeof(string)) { Value = secret.Properties.SecretUri });
infra.Add(new ProvisioningOutput("secretUri2", typeof(string)) { Value = secret2.Properties.SecretUri });
});

var (manifest, bicep) = await AzureManifestUtils.GetManifestWithBicep(module.Resource, skipPreparer: true);

await Verify(manifest.ToString(), "json")
.AppendContentAsFile(bicep, "bicep");
}

[Fact]
Expand All @@ -120,9 +188,9 @@ public void GetSecret_ReturnsSecretReference()
using var builder = TestDistributedApplicationBuilder.Create();

var kv = builder.AddAzureKeyVault("myKeyVault");

var secret = kv.GetSecret("mySecret");

Assert.NotNull(secret);
Assert.Equal("mySecret", secret.SecretName);
Assert.Same(kv.Resource, secret.Resource);
Expand All @@ -135,9 +203,9 @@ public void AddSecret_ReturnsSecretResource()

var secretParam = builder.AddParameter("secretParam", secret: true);
var kv = builder.AddAzureKeyVault("myKeyVault");

var secretResource = kv.AddSecret("mySecret", secretParam);

Assert.NotNull(secretResource);
Assert.IsType<AzureKeyVaultSecretResource>(secretResource.Resource);
Assert.Equal("mySecret", secretResource.Resource.Name);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ param api_containerimage string

param mydb_kv_outputs_name string

param kvName string

param sharedRg string

param api_identity_outputs_id string

param api_identity_outputs_clientid string
Expand All @@ -28,13 +32,23 @@ resource mainContainer 'Microsoft.Web/sites/sitecontainers@2024-11-01' = {
parent: webapp
}

resource mydb_kv_outputs_name_kv 'Microsoft.KeyVault/vaults@2024-11-01' existing = {
resource mydb_kv 'Microsoft.KeyVault/vaults@2024-11-01' existing = {
name: mydb_kv_outputs_name
}

resource mydb_kv_outputs_name_kv_connectionstrings__mydb 'Microsoft.KeyVault/vaults/secrets@2024-11-01' existing = {
resource mydb_kv_connectionstrings__mydb 'Microsoft.KeyVault/vaults/secrets@2024-11-01' existing = {
name: 'connectionstrings--mydb'
parent: mydb_kv_outputs_name_kv
parent: mydb_kv
}

resource existingKv 'Microsoft.KeyVault/vaults@2024-11-01' existing = {
name: kvName
scope: resourceGroup(sharedRg)
}

resource existingKv_secret 'Microsoft.KeyVault/vaults/secrets@2024-11-01' existing = {
name: 'secret'
parent: existingKv
}

resource webapp 'Microsoft.Web/sites@2024-11-01' = {
Expand Down Expand Up @@ -62,7 +76,11 @@ resource webapp 'Microsoft.Web/sites@2024-11-01' = {
}
{
name: 'ConnectionStrings__mydb'
value: '@Microsoft.KeyVault(SecretUri=${mydb_kv_outputs_name_kv_connectionstrings__mydb.properties.secretUri})'
value: '@Microsoft.KeyVault(SecretUri=${mydb_kv_connectionstrings__mydb.properties.secretUri})'
}
{
name: 'SECRET_VALUE'
value: '@Microsoft.KeyVault(SecretUri=${existingKv_secret.properties.secretUri})'
}
{
name: 'AZURE_CLIENT_ID'
Expand All @@ -78,4 +96,4 @@ resource webapp 'Microsoft.Web/sites@2024-11-01' = {
'${api_identity_outputs_id}': { }
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
"env_outputs_azure_container_registry_managed_identity_client_id": "{env.outputs.AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_CLIENT_ID}",
"api_containerimage": "{api.containerImage}",
"mydb_kv_outputs_name": "{mydb-kv.outputs.name}",
"kvName": "{kvName.value}",
"sharedRg": "{sharedRg.value}",
"api_identity_outputs_id": "{api-identity.outputs.id}",
"api_identity_outputs_clientid": "{api-identity.outputs.clientId}"
}
Expand Down
Loading