diff --git a/Directory.Packages.props b/Directory.Packages.props
index 24b9efe231f..43d5db88586 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -14,6 +14,13 @@
+
+
+
+
+
+
+
diff --git a/samples/DevHost/Program.cs b/samples/DevHost/Program.cs
index d50c00c1b23..3196301db7e 100644
--- a/samples/DevHost/Program.cs
+++ b/samples/DevHost/Program.cs
@@ -6,6 +6,8 @@
var builder = DistributedApplication.CreateBuilder(args);
+builder.AddAzureProvisioning();
+
var grafana = builder.AddContainer("grafana", "grafana/grafana")
.WithServiceBinding(containerPort: 3000, name: "grafana-http", scheme: "http");
@@ -18,7 +20,7 @@
.WithReplicas(2)
.WithSqlServer(sql, "master");
-var serviceBus = builder.AddAzureServiceBus("messaging");
+var serviceBus = builder.AddAzureServiceBus("messaging", "orders");
var basket = builder.AddProject()
.WithRedis(redis)
diff --git a/samples/OrderProcessor/OrderProcessingWorker.cs b/samples/OrderProcessor/OrderProcessingWorker.cs
index 6d3b04b3464..aa99cac7e23 100644
--- a/samples/OrderProcessor/OrderProcessingWorker.cs
+++ b/samples/OrderProcessor/OrderProcessingWorker.cs
@@ -31,7 +31,7 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
}
const string configKeyName = "Aspire:Azure:Messaging:ServiceBus:OrderQueueName";
- string queueName = _config[configKeyName] ?? throw new InvalidOperationException($"Queue name not found. Please add a valid name for configuration key '{configKeyName}'.");
+ string queueName = _config[configKeyName] ?? "orders";
_messageProcessor = _client.CreateProcessor(queueName);
_messageProcessor.ProcessMessageAsync += ProcessMessageAsync;
@@ -58,20 +58,24 @@ private Task ProcessMessageAsync(ProcessMessageEventArgs args)
_logger.LogInformation($"Processing Order at: {DateTime.UtcNow}");
var message = args.Message;
- _logger.LogDebug($"""
- MessageId:{message.MessageId}
- MessageBody:{message.Body}
- """);
+
+ if (_logger.Equals(LogLevel.Debug))
+ {
+ _logger.LogDebug("""
+ MessageId:{MessageId}
+ MessageBody:{Body}
+ """, message.MessageId, message.Body);
+ }
var order = message.Body.ToObjectFromJson();
activity?.AddTag("order-id", order.Id);
activity?.AddTag("product-count", order.Items.Count);
- _logger.LogCritical($"""
- OrderId:{order.Id}
- BuyerId:{order.BuyerId}
- ProductCount:{order.Items.Count}
- """);
+ _logger.LogInformation("""
+ OrderId:{Id}
+ BuyerId:{BuyerId}
+ ProductCount:{Count}
+ """, order.Id, order.BuyerId, order.Items.Count);
return Task.CompletedTask;
}
diff --git a/samples/OrderProcessor/Program.cs b/samples/OrderProcessor/Program.cs
index 94ea2f41816..4c4776af200 100644
--- a/samples/OrderProcessor/Program.cs
+++ b/samples/OrderProcessor/Program.cs
@@ -2,6 +2,8 @@
var builder = Host.CreateApplicationBuilder(args);
+builder.AddServiceDefaults();
+
// When running for Development, don't fail at startup if the developer hasn't configured ServiceBus yet.
if (!builder.Environment.IsDevelopment() || builder.Configuration[AspireServiceBusExtensions.DefaultNamespaceConfigKey] is not null)
{
diff --git a/src/Aspire.Hosting.Azure/Aspire.Hosting.Azure.csproj b/src/Aspire.Hosting.Azure/Aspire.Hosting.Azure.csproj
index 5571f67c19e..6227e522d37 100644
--- a/src/Aspire.Hosting.Azure/Aspire.Hosting.Azure.csproj
+++ b/src/Aspire.Hosting.Azure/Aspire.Hosting.Azure.csproj
@@ -1,4 +1,4 @@
-
+
$(NetCurrent)
@@ -7,6 +7,11 @@
+
+
+
+
+
diff --git a/src/Aspire.Hosting.Azure/AzureComponentExtensions.cs b/src/Aspire.Hosting.Azure/AzureComponentExtensions.cs
new file mode 100644
index 00000000000..693a55dd283
--- /dev/null
+++ b/src/Aspire.Hosting.Azure/AzureComponentExtensions.cs
@@ -0,0 +1,86 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Aspire.Hosting.ApplicationModel;
+using Aspire.Hosting.Lifecycle;
+
+namespace Aspire.Hosting.Azure;
+
+public static class AzureComponentExtensions
+{
+ public static IDistributedApplicationBuilder AddAzureProvisioning(this IDistributedApplicationBuilder builder)
+ {
+ builder.Services.AddLifecycleHook();
+ return builder;
+ }
+
+ public static IDistributedApplicationComponentBuilder AddAzureKeyVault(this IDistributedApplicationBuilder builder, string name)
+ {
+ var component = new AzureKeyVaultComponent();
+ return builder.AddComponent(name, component);
+ }
+
+ public static IDistributedApplicationComponentBuilder WithAddAzureKeyVault(this IDistributedApplicationComponentBuilder builder, IDistributedApplicationComponentBuilder keyvalut)
+ where T : IDistributedApplicationComponentWithEnvironment
+ {
+ return builder.WithEnvironment((env) =>
+ {
+ var vaultName = keyvalut.Component.VaultName ?? builder.ApplicationBuilder.Configuration["Aspire:Azure:Security:KeyVault:VaultName"];
+
+ if (vaultName is not null)
+ {
+ env[$"Aspire__Azure__Security__KeyVault__VaultUri"] = $"https://{vaultName}.vault.azure.net/";
+ }
+ });
+ }
+
+ public static IDistributedApplicationComponentBuilder AddAzureServiceBus(this IDistributedApplicationBuilder builder, string name, params string[] queueNames)
+ {
+ var component = new AzureServiceBusComponent
+ {
+ QueueNames = queueNames
+ };
+
+ return builder.AddComponent(name, component);
+ }
+
+ public static IDistributedApplicationComponentBuilder WithAzureServiceBus(this IDistributedApplicationComponentBuilder builder, IDistributedApplicationComponentBuilder serviceBus)
+ where T : IDistributedApplicationComponentWithEnvironment
+ {
+ return builder.WithEnvironment((env) =>
+ {
+ var sbNamespace = serviceBus.Component.ServiceBusNamespace ?? builder.ApplicationBuilder.Configuration["Aspire:Azure:Messaging:ServiceBus:Namespace"];
+
+ if (sbNamespace is not null)
+ {
+ env[$"Aspire__Azure__Messaging__ServiceBus__Namespace"] = $"{sbNamespace}.servicebus.windows.net";
+ }
+ });
+ }
+
+ public static IDistributedApplicationComponentBuilder AddAzureStorage(this IDistributedApplicationBuilder builder, string name)
+ {
+ var component = new AzureStorageComponent();
+ return builder.AddComponent(name, component);
+ }
+
+ public static IDistributedApplicationComponentBuilder WithAzureStorage(this IDistributedApplicationComponentBuilder builder, IDistributedApplicationComponentBuilder storage)
+ where T : IDistributedApplicationComponentWithEnvironment
+ {
+ return builder.WithEnvironment((env) =>
+ {
+ // We don't support connection strings yet
+ //storage.Component.TryGetName(out var name);
+ //env[$"ConnectionStrings__{name}"] = storage.Component.ConnectionString!;
+
+ var accountName = storage.Component.AccountName ?? builder.ApplicationBuilder.Configuration["Aspire:Azure:Storage:AccountName"];
+
+ if (accountName is not null)
+ {
+ env[$"Aspire__Azure__Data__Tables__ServiceUri"] = $"https://{accountName}.table.core.windows.net/";
+ env[$"Aspire__Azure__Storage__Blobs__ServiceUri"] = $"https://{accountName}.blob.core.windows.net/";
+ env[$"Aspire__Azure__Storage__Queues__ServiceUri"] = $"https://{accountName}.queue.core.windows.net/";
+ }
+ });
+ }
+}
diff --git a/src/Aspire.Hosting.Azure/AzureDistributedApplicationBuilderExtensions.cs b/src/Aspire.Hosting.Azure/AzureDistributedApplicationBuilderExtensions.cs
deleted file mode 100644
index f86559811c8..00000000000
--- a/src/Aspire.Hosting.Azure/AzureDistributedApplicationBuilderExtensions.cs
+++ /dev/null
@@ -1,46 +0,0 @@
-// Licensed to the .NET Foundation under one or more agreements.
-// The .NET Foundation licenses this file to you under the MIT license.
-
-using Aspire.Hosting.ApplicationModel;
-
-namespace Aspire.Hosting.Azure;
-
-public static class AzureDistributedApplicationBuilderExtensions
-{
- ///
- /// Adds an Azure Service Bus component to the .
- ///
- /// The builder to add the component to.
- /// The name of the component.
- ///
- /// The optional Service Bus namespace to use for this component. If not specified, uses the 'Aspire.Azure.Messaging.ServiceBus:Namespace' configuration value
- /// of .Configuration.
- ///
- public static IDistributedApplicationComponentBuilder AddAzureServiceBus(this IDistributedApplicationBuilder builder, string name, string? serviceBusNamespace = null)
- {
- var componentBuilder = builder.AddComponent(name, new ServiceBusComponent());
- componentBuilder.WithAnnotation(new ServiceBusAnnotation(serviceBusNamespace));
- return componentBuilder;
- }
-
- ///
- /// Adds the Azure ServiceBus represented by as a service that can be used by the Project represented by .
- ///
- public static IDistributedApplicationComponentBuilder WithAzureServiceBus(this IDistributedApplicationComponentBuilder projectBuilder, IDistributedApplicationComponentBuilder serviceBusBuilder)
- {
- return projectBuilder.WithEnvironment((config) =>
- {
- if (!serviceBusBuilder.Component.TryGetLastAnnotation(out var sbAnnotation))
- {
- throw new DistributedApplicationException("Service bus component must have a ServiceBusAnnotation.");
- }
-
- var namespaceName = sbAnnotation.Namespace ?? projectBuilder.ApplicationBuilder.Configuration["Aspire:Azure:Messaging:ServiceBus:Namespace"];
-
- if (namespaceName is not null)
- {
- config.Add("Aspire__Azure__Messaging__ServiceBus__Namespace", namespaceName);
- }
- });
- }
-}
diff --git a/src/Aspire.Hosting.Azure/ServiceBusComponent.cs b/src/Aspire.Hosting.Azure/AzureKeyVaultComponent.cs
similarity index 74%
rename from src/Aspire.Hosting.Azure/ServiceBusComponent.cs
rename to src/Aspire.Hosting.Azure/AzureKeyVaultComponent.cs
index aeaa77c53a8..2ca61831a77 100644
--- a/src/Aspire.Hosting.Azure/ServiceBusComponent.cs
+++ b/src/Aspire.Hosting.Azure/AzureKeyVaultComponent.cs
@@ -5,7 +5,9 @@
namespace Aspire.Hosting.Azure;
-public class ServiceBusComponent : IDistributedApplicationComponent
+public class AzureKeyVaultComponent : IAzureComponent
{
public ComponentMetadataCollection Annotations { get; } = new();
+
+ public string? VaultName { get; set; }
}
diff --git a/src/Aspire.Hosting.Azure/AzureProvisioner.cs b/src/Aspire.Hosting.Azure/AzureProvisioner.cs
new file mode 100644
index 00000000000..1b09a2fc359
--- /dev/null
+++ b/src/Aspire.Hosting.Azure/AzureProvisioner.cs
@@ -0,0 +1,468 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Text.Json;
+using Aspire.Hosting.ApplicationModel;
+using Aspire.Hosting.Lifecycle;
+using Azure;
+using Azure.Core;
+using Azure.Identity;
+using Azure.ResourceManager;
+using Azure.ResourceManager.Authorization;
+using Azure.ResourceManager.Authorization.Models;
+using Azure.ResourceManager.KeyVault;
+using Azure.ResourceManager.KeyVault.Models;
+using Azure.ResourceManager.Resources;
+using Azure.ResourceManager.ServiceBus;
+using Azure.ResourceManager.Storage;
+using Azure.ResourceManager.Storage.Models;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+
+namespace Aspire.Hosting.Azure;
+
+// Provisions azure resources for development purposes
+internal sealed class AzureProvisioner(IConfiguration configuration, IHostEnvironment environment, ILogger logger) : IDistributedApplicationLifecycleHook
+{
+ public async Task BeforeStartAsync(DistributedApplicationModel appModel, CancellationToken cancellationToken = default)
+ {
+ var azureComponents = appModel.Components.OfType();
+ if (!azureComponents.OfType().Any())
+ {
+ return;
+ }
+
+ try
+ {
+ await ProvisionAzureComponents(configuration, environment, logger, azureComponents, cancellationToken).ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Error provisioning azure components.");
+ }
+ }
+
+ private async Task ProvisionAzureComponents(IConfiguration configuration, IHostEnvironment environment, ILogger logger, IEnumerable azureComponents, CancellationToken cancellationToken)
+ {
+ var credential = new DefaultAzureCredential();
+
+ var subscriptionId = configuration["Azure:SubscriptionId"] ?? throw new InvalidOperationException("An azure subscription id is required. Set the Azure:SubscriptionId configuration value.");
+ var location = configuration["Azure:Location"] switch
+ {
+ null => throw new InvalidOperationException("An azure location/region is required. Set the Azure:Location configuration value."),
+ string loc => new AzureLocation(loc)
+ };
+
+ var armClient = new ArmClient(credential, subscriptionId);
+
+ logger.LogInformation("Getting default subscription...");
+
+ var subscription = await armClient.GetDefaultSubscriptionAsync(cancellationToken).ConfigureAwait(false);
+
+ logger.LogInformation("Default subscription: {name} ({subscriptionId})", subscription.Id, subscription.Data.DisplayName);
+
+ // Name of the resource group to create based on the machine name and application name
+ var (resourceGroupName, createIfNoExists) = configuration["Azure:ResourceGroup"] switch
+ {
+ null => ($"{Environment.MachineName.ToLowerInvariant()}-{environment.ApplicationName.ToLowerInvariant()}-rg", true),
+ string rg => (rg, false)
+ };
+
+ var resourceGroups = subscription.GetResourceGroups();
+ ResourceGroupResource? resourceGroup;
+
+ try
+ {
+ var response = await resourceGroups.GetAsync(resourceGroupName, cancellationToken).ConfigureAwait(false);
+ resourceGroup = response.Value;
+ location = resourceGroup.Data.Location;
+
+ logger.LogInformation("Using existing resource group {rgName}.", resourceGroup.Data.Name);
+ }
+ catch (Exception)
+ {
+ if (!createIfNoExists)
+ {
+ throw;
+ }
+
+ // REVIEW: Is it possible to do this without an exception?
+
+ logger.LogInformation("Creating resource group {rgName} in {location}...", resourceGroupName, location);
+
+ var rgData = new ResourceGroupData(location);
+ rgData.Tags.Add("aspire", "true");
+ var operation = await resourceGroups.CreateOrUpdateAsync(WaitUntil.Completed, resourceGroupName, rgData, cancellationToken).ConfigureAwait(false);
+ resourceGroup = operation.Value;
+
+ logger.LogInformation("Resource group {rgName} created.", resourceGroup.Data.Name);
+ }
+
+ var principalId = Guid.Parse(await GetUserPrincipalAsync(credential, cancellationToken).ConfigureAwait(false));
+
+ // Create a dictionary from component name to StorageAccountResource using the tag aspire-component-name to find the storage account
+ var storageAccounts = resourceGroup.GetStorageAccounts();
+ var componentNameToStorageAccountMap = new Dictionary();
+
+ await foreach (var a in storageAccounts.GetAllAsync(cancellationToken: cancellationToken))
+ {
+ if (a.Data.Tags.TryGetValue("aspire-component-name", out var aspireName))
+ {
+ componentNameToStorageAccountMap.Add(aspireName, a);
+ }
+ }
+
+ var serviceBusNamespaces = resourceGroup.GetServiceBusNamespaces();
+ var componentNameToServiceBusNamespaceMap = new Dictionary();
+
+ await foreach (var ns in serviceBusNamespaces.GetAllAsync(cancellationToken: cancellationToken))
+ {
+ if (ns.Data.Tags.TryGetValue("aspire-component-name", out var aspireName))
+ {
+ componentNameToServiceBusNamespaceMap.Add(aspireName, ns);
+ }
+ }
+
+ var keyVaults = resourceGroup.GetKeyVaults();
+ var componentNameToKeyVaultMap = new Dictionary();
+
+ await foreach (var kv in keyVaults.GetAllAsync(cancellationToken: cancellationToken))
+ {
+ if (kv.Data.Tags.TryGetValue("aspire-component-name", out var aspireName))
+ {
+ componentNameToKeyVaultMap.Add(aspireName, kv);
+ }
+ }
+
+ var tasks = new List();
+
+ foreach (var c in azureComponents)
+ {
+ if (c is AzureStorageComponent storage)
+ {
+ var task = CreateStorageAccountAsync(armClient,
+ subscription,
+ storageAccounts,
+ componentNameToStorageAccountMap,
+ location,
+ storage,
+ principalId,
+ cancellationToken);
+
+ tasks.Add(task);
+
+ c.TryGetName(out var name);
+ componentNameToStorageAccountMap.Remove(name!);
+ }
+
+ if (c is AzureServiceBusComponent serviceBus)
+ {
+ var task = CreateServiceBusAsync(armClient,
+ subscription,
+ serviceBusNamespaces,
+ componentNameToServiceBusNamespaceMap,
+ location,
+ serviceBus,
+ principalId,
+ cancellationToken);
+
+ tasks.Add(task);
+
+ c.TryGetName(out var name);
+ componentNameToServiceBusNamespaceMap.Remove(name!);
+ }
+
+ if (c is AzureKeyVaultComponent keyVault)
+ {
+ var task = CreateKeyVaultAsync(armClient,
+ subscription,
+ keyVaults,
+ componentNameToKeyVaultMap,
+ location,
+ keyVault,
+ principalId,
+ cancellationToken);
+
+ tasks.Add(task);
+
+ c.TryGetName(out var name);
+ componentNameToKeyVaultMap.Remove(name!);
+ }
+ }
+
+ await Task.WhenAll(tasks).ConfigureAwait(false);
+
+ // Clean up any left over resources that are no longer in the model
+ foreach (var (name, sa) in componentNameToStorageAccountMap)
+ {
+ logger.LogInformation("Deleting storage account {accountName} which maps to component name {name}.", sa.Id, name);
+
+ await sa.DeleteAsync(WaitUntil.Started, cancellationToken).ConfigureAwait(false);
+ }
+
+ foreach (var (name, ns) in componentNameToServiceBusNamespaceMap)
+ {
+ logger.LogInformation("Deleting service bus namespace {namespaceName} which maps to component name {name}.", ns.Id, name);
+
+ await ns.DeleteAsync(WaitUntil.Started, cancellationToken).ConfigureAwait(false);
+ }
+
+ foreach (var (name, kv) in componentNameToKeyVaultMap)
+ {
+ logger.LogInformation("Deleting key vault {keyVaultName} which maps to component name {name}.", kv.Id, name);
+
+ await kv.DeleteAsync(WaitUntil.Started, cancellationToken).ConfigureAwait(false);
+ }
+ }
+
+ private async Task CreateKeyVaultAsync(
+ ArmClient armClient,
+ SubscriptionResource subscription,
+ KeyVaultCollection keyVaults,
+ Dictionary componentNameToKeyVaultMap,
+ AzureLocation location,
+ AzureKeyVaultComponent keyVault,
+ Guid principalId,
+ CancellationToken cancellationToken)
+ {
+ keyVault.TryGetName(out var name);
+ componentNameToKeyVaultMap.TryGetValue(name!, out var keyVaultResource);
+
+ if (keyVaultResource is null)
+ {
+ // A vault's name must be between 3-24 alphanumeric characters. The name must begin with a letter, end with a letter or digit, and not contain consecutive hyphens.
+ // Follow this link for more information: https://go.microsoft.com/fwlink/?linkid=2147742
+ var vaultName = $"v{Guid.NewGuid().ToString().Replace("-", string.Empty)[0..20]}";
+
+ logger.LogInformation("Creating key vault {vaultName} in {location}...", vaultName, location);
+
+ var properties = new KeyVaultProperties(subscription.Data.TenantId!.Value, new KeyVaultSku(KeyVaultSkuFamily.A, KeyVaultSkuName.Standard))
+ {
+ EnabledForTemplateDeployment = true,
+ EnableRbacAuthorization = true
+ };
+ var parameters = new KeyVaultCreateOrUpdateContent(location, properties);
+ parameters.Tags.Add("aspire-component-name", name);
+
+ var operation = await keyVaults.CreateOrUpdateAsync(WaitUntil.Completed, vaultName, parameters, cancellationToken).ConfigureAwait(false);
+ keyVaultResource = operation.Value;
+
+ logger.LogInformation("Key vault {vaultName} created.", keyVaultResource.Data.Name);
+ }
+
+ keyVault.VaultName = keyVaultResource.Data.Name;
+
+ // Key Vault Administrator
+ // https://learn.microsoft.com/en-us/azure/role-based-access-control/built-in-roles#key-vault-administrator
+ var roleDefinitionId = CreateRoleDefinitionId(subscription, "00482a5a-887f-4fb3-b363-3b7fe8e74483");
+
+ await DoRoleAssignmentAsync(armClient, keyVaultResource.Id, principalId, roleDefinitionId, cancellationToken).ConfigureAwait(false);
+ }
+
+ private async Task CreateServiceBusAsync(
+ ArmClient armClient,
+ SubscriptionResource subscription,
+ ServiceBusNamespaceCollection serviceBusNamespaces,
+ Dictionary componentNameToServiceBusNamespaceMap,
+ AzureLocation location,
+ AzureServiceBusComponent component,
+ Guid principalId,
+ CancellationToken cancellationToken)
+ {
+ component.TryGetName(out var name);
+ componentNameToServiceBusNamespaceMap.TryGetValue(name!, out var serviceBusNamespace);
+
+ if (serviceBusNamespace is null)
+ {
+ // ^[a-zA-Z][a-zA-Z0-9-]*$
+ var namespaceName = Guid.NewGuid().ToString();
+
+ logger.LogInformation("Creating service bus namespace {namespace} in {location}...", namespaceName, location);
+
+ var parameters = new ServiceBusNamespaceData(location);
+ parameters.Tags.Add("aspire-component-name", name);
+
+ // Now we can create a storage account with defined account name and parameters
+ var operation = await serviceBusNamespaces.CreateOrUpdateAsync(WaitUntil.Completed, namespaceName, parameters, cancellationToken).ConfigureAwait(false);
+ serviceBusNamespace = operation.Value;
+
+ logger.LogInformation("Service bus namespace {namespace} created.", serviceBusNamespace.Data.Name);
+ }
+
+ component.ServiceBusNamespace = serviceBusNamespace.Data.Name;
+
+ // Now create the queues
+ var queues = serviceBusNamespace.GetServiceBusQueues();
+
+ var queuesToCreate = new HashSet(component.QueueNames);
+
+ // Delete unused queues
+ await foreach (var sbQueue in queues.GetAllAsync(cancellationToken: cancellationToken))
+ {
+ if (!component.QueueNames.Contains(sbQueue.Data.Name))
+ {
+ logger.LogInformation("Deleting queue {queueName}", sbQueue.Data.Name);
+
+ await sbQueue.DeleteAsync(WaitUntil.Started, cancellationToken).ConfigureAwait(false);
+ }
+
+ // Don't need to create this queue
+ queuesToCreate.Remove(sbQueue.Data.Name);
+ }
+
+ // Create the remaining queues
+ foreach (var queueName in queuesToCreate)
+ {
+ logger.LogInformation("Creating queue {queueName}...", queueName);
+
+ await queues.CreateOrUpdateAsync(WaitUntil.Completed, queueName, new ServiceBusQueueData(), cancellationToken).ConfigureAwait(false);
+
+ logger.LogInformation("Queue {queueName} created.", queueName);
+ }
+
+ // Azure Service Bus Data Owner
+ // https://learn.microsoft.com/en-us/azure/role-based-access-control/built-in-roles#azure-service-bus-data-owner
+ var roleDefinitionId = CreateRoleDefinitionId(subscription, "090c5cfd-751d-490a-894a-3ce6f1109419");
+
+ await DoRoleAssignmentAsync(armClient, serviceBusNamespace.Id, principalId, roleDefinitionId, cancellationToken).ConfigureAwait(false);
+ }
+
+ private async Task CreateStorageAccountAsync(
+ ArmClient armClient,
+ SubscriptionResource subscription,
+ StorageAccountCollection storageAccounts,
+ Dictionary componentNameToStorageAccountMap,
+ AzureLocation location,
+ AzureStorageComponent component,
+ Guid principalId,
+ CancellationToken cancellationToken)
+ {
+ component.TryGetName(out var name);
+ componentNameToStorageAccountMap.TryGetValue(name!, out var storageAccount);
+
+ if (storageAccount is null)
+ {
+ // Storage account name must be between 3 and 24 characters in length and use numbers and lower-case letters only.
+ var accountName = Guid.NewGuid().ToString().Replace("-", string.Empty)[0..20];
+
+ logger.LogInformation("Creating storage account {accountName} in {location}...", accountName, location);
+
+ // First we need to define the StorageAccountCreateParameters
+ var sku = new StorageSku(StorageSkuName.StandardGrs);
+ var kind = StorageKind.Storage;
+ var parameters = new StorageAccountCreateOrUpdateContent(sku, kind, location);
+ parameters.Tags.Add("aspire-component-name", name);
+
+ // Now we can create a storage account with defined account name and parameters
+ var accountCreateOperation = await storageAccounts.CreateOrUpdateAsync(WaitUntil.Completed, accountName, parameters, cancellationToken).ConfigureAwait(false);
+ storageAccount = accountCreateOperation.Value;
+
+ logger.LogInformation("Storage account {accountName} created.", storageAccount.Data.Name);
+ }
+
+ // Our storage component doesn't support connection strings yet
+
+ // Get the storage account key
+ //string accountKey = "";
+ //await foreach (StorageAccountKey key in storageAccount.GetKeysAsync(cancellationToken: cancellationToken))
+ //{
+ // accountKey = key.Value;
+ // break;
+ //}
+
+ // component.ConnectionString = $"DefaultEndpointsProtocol=https;AccountName={accountName};AccountKey={accountKey}==;EndpointSuffix=core.windows.net";
+
+ component.AccountName = storageAccount.Data.Name;
+
+ // Storage Queue Data Contributor
+ // https://learn.microsoft.com/en-us/azure/role-based-access-control/built-in-roles#storage-queue-data-contributor
+ var storageQueueDataContributorId = CreateRoleDefinitionId(subscription, "974c5e8b-45b9-4653-ba55-5f855dd0fb88");
+
+ // Storage Table Data Contributor
+ // https://learn.microsoft.com/en-us/azure/role-based-access-control/built-in-roles#storage-table-data-contributor
+ var storageDataContributorId = CreateRoleDefinitionId(subscription, "0a9a7e1f-b9d0-4cc4-a60d-0319b160aaa3");
+
+ // Storage Blob Data Contributor
+ // https://learn.microsoft.com/en-us/azure/role-based-access-control/built-in-roles#storage-blob-data-contributor
+ var storageBlobDataContributorId = CreateRoleDefinitionId(subscription, "81a9662b-bebf-436f-a333-f67b29880f12");
+
+ var t0 = DoRoleAssignmentAsync(armClient, storageAccount.Id, principalId, storageQueueDataContributorId, cancellationToken);
+ var t1 = DoRoleAssignmentAsync(armClient, storageAccount.Id, principalId, storageDataContributorId, cancellationToken);
+ var t2 = DoRoleAssignmentAsync(armClient, storageAccount.Id, principalId, storageBlobDataContributorId, cancellationToken);
+
+ await Task.WhenAll(t0, t1, t2).ConfigureAwait(false);
+ }
+
+ private static ResourceIdentifier CreateRoleDefinitionId(SubscriptionResource subscription, string roleDefinitionId) =>
+ new($"{subscription.Id}/providers/Microsoft.Authorization/roleDefinitions/{roleDefinitionId}");
+
+ private async Task DoRoleAssignmentAsync(
+ ArmClient armClient,
+ ResourceIdentifier resourceId,
+ Guid principalId,
+ ResourceIdentifier roleDefinitionId,
+ CancellationToken cancellationToken)
+ {
+ var roleAssignments = armClient.GetRoleAssignments(resourceId);
+ await foreach (var ra in roleAssignments.GetAllAsync(cancellationToken: cancellationToken))
+ {
+ if (ra.Data.PrincipalId == principalId &&
+ ra.Data.RoleDefinitionId.Equals(roleDefinitionId))
+ {
+ return;
+ }
+ }
+
+ logger.LogInformation("Assigning role {role} to {principalId}...", roleDefinitionId, principalId);
+
+ var roleAssignmentInfo = new RoleAssignmentCreateOrUpdateContent(roleDefinitionId, principalId);
+
+ var roleAssignmentId = Guid.NewGuid().ToString();
+ await roleAssignments.CreateOrUpdateAsync(WaitUntil.Completed, roleAssignmentId, roleAssignmentInfo, cancellationToken).ConfigureAwait(false);
+
+ logger.LogInformation("Role {role} assigned to {principalId}.", roleDefinitionId, principalId);
+ }
+
+ internal async Task GetUserPrincipalAsync(TokenCredential credential, CancellationToken cancellationToken)
+ {
+ var response = await credential.GetTokenAsync(new(["https://graph.windows.net/.default"]), cancellationToken).ConfigureAwait(false);
+
+ static string ParseToken(in AccessToken response)
+ {
+ // Parse the access token to get the user's object id (this is their principal id)
+
+ var parts = response.Token.Split('.');
+ var part = parts[1];
+ var convertedToken = part.ToString().Replace('_', '/').Replace('-', '+');
+
+ switch (part.Length % 4)
+ {
+ case 2:
+ convertedToken += "==";
+ break;
+ case 3:
+ convertedToken += "=";
+ break;
+ }
+ var bytes = Convert.FromBase64String(convertedToken);
+ Utf8JsonReader reader = new(bytes);
+ while (reader.Read())
+ {
+ if (reader.TokenType == JsonTokenType.PropertyName)
+ {
+ var header = reader.GetString();
+ if (header == "oid")
+ {
+ reader.Read();
+ return reader.GetString()!;
+ }
+ reader.Read();
+ }
+ }
+ return string.Empty;
+ }
+
+ return ParseToken(response);
+ }
+}
diff --git a/src/Aspire.Hosting.Azure/AzureServiceBusComponent.cs b/src/Aspire.Hosting.Azure/AzureServiceBusComponent.cs
new file mode 100644
index 00000000000..a33835eeb21
--- /dev/null
+++ b/src/Aspire.Hosting.Azure/AzureServiceBusComponent.cs
@@ -0,0 +1,15 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Aspire.Hosting.ApplicationModel;
+
+namespace Aspire.Hosting.Azure;
+
+public class AzureServiceBusComponent : IAzureComponent
+{
+ public ComponentMetadataCollection Annotations { get; } = new();
+
+ public string ServiceBusNamespace { get; set; } = default!;
+
+ public string[] QueueNames { get; set; } = [];
+}
diff --git a/src/Aspire.Hosting.Azure/AzureStorageComponent.cs b/src/Aspire.Hosting.Azure/AzureStorageComponent.cs
new file mode 100644
index 00000000000..1c17406a3d2
--- /dev/null
+++ b/src/Aspire.Hosting.Azure/AzureStorageComponent.cs
@@ -0,0 +1,14 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Aspire.Hosting.ApplicationModel;
+
+namespace Aspire.Hosting.Azure;
+
+public class AzureStorageComponent : IAzureComponent
+{
+ public ComponentMetadataCollection Annotations { get; } = new();
+
+ public string? ConnectionString { get; set; }
+ public string? AccountName { get; set; }
+}
diff --git a/src/Aspire.Hosting.Azure/ServiceBusAnnotation.cs b/src/Aspire.Hosting.Azure/IAzureComponent.cs
similarity index 58%
rename from src/Aspire.Hosting.Azure/ServiceBusAnnotation.cs
rename to src/Aspire.Hosting.Azure/IAzureComponent.cs
index 4484eae25ab..94aea368be6 100644
--- a/src/Aspire.Hosting.Azure/ServiceBusAnnotation.cs
+++ b/src/Aspire.Hosting.Azure/IAzureComponent.cs
@@ -5,7 +5,7 @@
namespace Aspire.Hosting.Azure;
-public class ServiceBusAnnotation(string? @namespace): IDistributedApplicationComponentAnnotation
+public interface IAzureComponent : IDistributedApplicationComponent
{
- public string? Namespace { get; } = @namespace;
+
}