diff --git a/Aspire.slnx b/Aspire.slnx index 9e145cd7c14..e8fd10007eb 100644 --- a/Aspire.slnx +++ b/Aspire.slnx @@ -161,6 +161,9 @@ + + + diff --git a/playground/deployers/Deployers.AppHost/AppHost.cs b/playground/deployers/Deployers.AppHost/AppHost.cs new file mode 100644 index 00000000000..911dfe3c44c --- /dev/null +++ b/playground/deployers/Deployers.AppHost/AppHost.cs @@ -0,0 +1,22 @@ +var builder = DistributedApplication.CreateBuilder(args); + +builder.AddAzureContainerAppEnvironment("env"); + +var storage = builder.AddAzureStorage("storage"); + +storage.AddBlobs("blobs"); +storage.AddBlobContainer("mycontainer1", blobContainerName: "test-container-1"); +storage.AddBlobContainer("mycontainer2", blobContainerName: "test-container-2"); +storage.AddQueue("myqueue", queueName: "my-queue"); + +#if !SKIP_DASHBOARD_REFERENCE +// This project is only added in playground projects to support development/debugging +// of the dashboard. It is not required in end developer code. Comment out this code +// or build with `/p:SkipDashboardReference=true`, to test end developer +// dashboard launch experience, Refer to Directory.Build.props for the path to +// the dashboard binary (defaults to the Aspire.Dashboard bin output in the +// artifacts dir). +builder.AddProject(KnownResourceNames.AspireDashboard); +#endif + +builder.Build().Run(); diff --git a/playground/deployers/Deployers.AppHost/Deployers.AppHost.csproj b/playground/deployers/Deployers.AppHost/Deployers.AppHost.csproj new file mode 100644 index 00000000000..75841a8532f --- /dev/null +++ b/playground/deployers/Deployers.AppHost/Deployers.AppHost.csproj @@ -0,0 +1,21 @@ + + + + Exe + $(DefaultTargetFramework) + enable + enable + true + + + + + + + + + + + + + diff --git a/playground/deployers/Deployers.AppHost/Properties/launchSettings.json b/playground/deployers/Deployers.AppHost/Properties/launchSettings.json new file mode 100644 index 00000000000..79731a8c75e --- /dev/null +++ b/playground/deployers/Deployers.AppHost/Properties/launchSettings.json @@ -0,0 +1,33 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:17222;http://localhost:15020", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21267", + "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22116" + } + }, + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:15020", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19093", + "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20094" + } + }, + "deploy": { + "commandName": "Project", + "commandLineArgs": "--publisher default --deploy true --output-path deploy", + } + } +} diff --git a/playground/deployers/Deployers.AppHost/appsettings.Development.json b/playground/deployers/Deployers.AppHost/appsettings.Development.json new file mode 100644 index 00000000000..0c208ae9181 --- /dev/null +++ b/playground/deployers/Deployers.AppHost/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/playground/deployers/Deployers.AppHost/appsettings.json b/playground/deployers/Deployers.AppHost/appsettings.json new file mode 100644 index 00000000000..31c092aa450 --- /dev/null +++ b/playground/deployers/Deployers.AppHost/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Aspire.Hosting.Dcp": "Warning" + } + } +} diff --git a/src/Aspire.Hosting.Azure/AzureBicepResource.cs b/src/Aspire.Hosting.Azure/AzureBicepResource.cs index af15f13767c..028e256d53f 100644 --- a/src/Aspire.Hosting.Azure/AzureBicepResource.cs +++ b/src/Aspire.Hosting.Azure/AzureBicepResource.cs @@ -120,7 +120,8 @@ public virtual BicepTemplateFile GetBicepTemplateFile(string? directory = null, } } - return new(path, isTempFile && deleteTemporaryFileOnDispose); + var targetPath = directory is not null ? Path.Combine(directory, path) : path; + return new(targetPath, isTempFile && deleteTemporaryFileOnDispose); } /// diff --git a/src/Aspire.Hosting.Azure/AzureDeployingContext.cs b/src/Aspire.Hosting.Azure/AzureDeployingContext.cs index 056447eff37..03efbc4b623 100644 --- a/src/Aspire.Hosting.Azure/AzureDeployingContext.cs +++ b/src/Aspire.Hosting.Azure/AzureDeployingContext.cs @@ -4,17 +4,74 @@ #pragma warning disable ASPIREAZURE001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. #pragma warning disable ASPIREPUBLISHERS001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. +using Aspire.Hosting.Azure.Provisioning; using Aspire.Hosting.Azure.Provisioning.Internal; +using Aspire.Hosting.Publishing; namespace Aspire.Hosting.Azure; internal sealed class AzureDeployingContext( IProvisioningContextProvider provisioningContextProvider, - IUserSecretsManager userSecretsManager) + IUserSecretsManager userSecretsManager, + IBicepProvisioner bicepProvisioner, + IPublishingActivityReporter activityReporter) { - public async Task DeployModelAsync(CancellationToken cancellationToken = default) + public async Task DeployModelAsync(AzureEnvironmentResource resource, CancellationToken cancellationToken = default) { var userSecrets = await userSecretsManager.LoadUserSecretsAsync(cancellationToken).ConfigureAwait(false); - await provisioningContextProvider.CreateProvisioningContextAsync(userSecrets, cancellationToken).ConfigureAwait(false); + var provisioningContext = await provisioningContextProvider.CreateProvisioningContextAsync(userSecrets, cancellationToken).ConfigureAwait(false); + + if (resource.PublishingContext is null) + { + throw new InvalidOperationException($"Publishing context is not initialized. Please ensure that the {nameof(AzurePublishingContext)} has been initialized before deploying."); + } + + var deployingStep = await activityReporter.CreateStepAsync("Deploying to Azure", cancellationToken).ConfigureAwait(false); + await using (deployingStep.ConfigureAwait(false)) + { + // Map parameters from the AzurePublishingContext + foreach (var (parameterResource, provisioningParameter) in resource.PublishingContext.ParameterLookup) + { + if (parameterResource == resource.Location) + { + resource.Parameters[provisioningParameter.BicepIdentifier] = provisioningContext.Location.Name; + } + else if (parameterResource == resource.ResourceGroupName) + { + resource.Parameters[provisioningParameter.BicepIdentifier] = provisioningContext.ResourceGroup.Name; + } + else if (parameterResource == resource.PrincipalId) + { + resource.Parameters[provisioningParameter.BicepIdentifier] = provisioningContext.Principal.Id.ToString(); + } + else + { + // TODO: Prompt here. + await deployingStep.FailAsync("Deployment contains unresolvable parameters.", cancellationToken).ConfigureAwait(false); + } + } + + try + { + var azureTask = await deployingStep.CreateTaskAsync("Provisioning Azure environment", cancellationToken).ConfigureAwait(false); + await using (azureTask.ConfigureAwait(false)) + { + try + { + await bicepProvisioner.GetOrCreateResourceAsync(resource, provisioningContext, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + await azureTask.FailAsync($"Provisioning failed: {ex.Message}", cancellationToken).ConfigureAwait(false); + throw; + } + } + } + catch (Exception ex) + { + await deployingStep.FailAsync($"Deployment failed: {ex.Message}", cancellationToken).ConfigureAwait(false); + throw; + } + } } } diff --git a/src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs b/src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs index 76359a14d6e..af2c613e40f 100644 --- a/src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs +++ b/src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs @@ -6,7 +6,9 @@ using System.Diagnostics.CodeAnalysis; using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.Azure.Provisioning; using Aspire.Hosting.Azure.Provisioning.Internal; +using Aspire.Hosting.Publishing; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; @@ -17,7 +19,7 @@ namespace Aspire.Hosting.Azure; /// Emits a main.bicep that aggregates all provisionable resources. /// [Experimental("ASPIREAZURE001", UrlFormat = "https://aka.ms/dotnet/aspire/diagnostics#{0}")] -public sealed class AzureEnvironmentResource : Resource +public sealed class AzureEnvironmentResource : AzureBicepResource { /// /// Gets or sets the Azure location that the resources will be deployed to. @@ -34,6 +36,8 @@ public sealed class AzureEnvironmentResource : Resource /// public ParameterResource PrincipalId { get; set; } + internal AzurePublishingContext? PublishingContext { get; set; } + /// /// Initializes a new instance of the class. /// @@ -43,10 +47,11 @@ public sealed class AzureEnvironmentResource : Resource /// The Azure principal ID that will be used to deploy the resources. /// Thrown when the name is null or empty. /// Thrown when the name is invalid. - public AzureEnvironmentResource(string name, ParameterResource location, ParameterResource resourceGroupName, ParameterResource principalId) : base(name) + public AzureEnvironmentResource(string name, ParameterResource location, ParameterResource resourceGroupName, ParameterResource principalId) : base(name, templateFile: "main.bicep") { Annotations.Add(new PublishingCallbackAnnotation(PublishAsync)); Annotations.Add(new DeployingCallbackAnnotation(DeployAsync)); + Annotations.Add(ManifestPublishingCallbackAnnotation.Ignore); Location = location; ResourceGroupName = resourceGroupName; @@ -57,24 +62,28 @@ private Task PublishAsync(PublishingContext context) { var azureProvisioningOptions = context.Services.GetRequiredService>(); - var azureCtx = new AzurePublishingContext( + PublishingContext = new AzurePublishingContext( context.OutputPath, azureProvisioningOptions.Value, context.Logger, context.ActivityReporter); - return azureCtx.WriteModelAsync(context.Model, this); + return PublishingContext.WriteModelAsync(context.Model, this); } private Task DeployAsync(DeployingContext context) { var provisioningContextProvider = context.Services.GetRequiredService(); var userSecretsManager = context.Services.GetRequiredService(); + var bicepProvisioner = context.Services.GetRequiredService(); + var activityPublisher = context.Services.GetRequiredService(); var azureCtx = new AzureDeployingContext( provisioningContextProvider, - userSecretsManager); + userSecretsManager, + bicepProvisioner, + activityPublisher); - return azureCtx.DeployModelAsync(context.CancellationToken); + return azureCtx.DeployModelAsync(this, context.CancellationToken); } } diff --git a/src/Aspire.Hosting.Azure/AzurePublishingContext.cs b/src/Aspire.Hosting.Azure/AzurePublishingContext.cs index defb3781102..8183a1e7454 100644 --- a/src/Aspire.Hosting.Azure/AzurePublishingContext.cs +++ b/src/Aspire.Hosting.Azure/AzurePublishingContext.cs @@ -251,6 +251,11 @@ static BicepValue ResolveValue(object val) module.Parameters.Add(parameter.Key, principalId); continue; } + if (parameter.Key == AzureBicepResource.KnownParameters.PrincipalId && parameter.Value is null) + { + module.Parameters.Add(parameter.Key, principalId); + continue; + } var value = ResolveValue(Eval(parameter.Value)); diff --git a/src/Aspire.Hosting.Azure/Provisioning/AzureProvisionerExtensions.cs b/src/Aspire.Hosting.Azure/Provisioning/AzureProvisionerExtensions.cs index 05d99857256..61bfd7a6d08 100644 --- a/src/Aspire.Hosting.Azure/Provisioning/AzureProvisionerExtensions.cs +++ b/src/Aspire.Hosting.Azure/Provisioning/AzureProvisionerExtensions.cs @@ -36,8 +36,8 @@ public static IDistributedApplicationBuilder AddAzureProvisioning(this IDistribu builder.Services.AddSingleton(); - // Register BicepProvisioner directly - builder.Services.AddSingleton(); + // Register BicepProvisioner via interface + builder.Services.AddSingleton(); // Register the new internal services for testability builder.Services.AddSingleton(); diff --git a/src/Aspire.Hosting.Azure/Provisioning/Internal/DefaultArmDeploymentCollection.cs b/src/Aspire.Hosting.Azure/Provisioning/Internal/DefaultArmDeploymentCollection.cs new file mode 100644 index 00000000000..c948ddf6d91 --- /dev/null +++ b/src/Aspire.Hosting.Azure/Provisioning/Internal/DefaultArmDeploymentCollection.cs @@ -0,0 +1,21 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Azure; +using Azure.ResourceManager; +using Azure.ResourceManager.Resources; +using Azure.ResourceManager.Resources.Models; + +namespace Aspire.Hosting.Azure.Provisioning.Internal; + +internal sealed class DefaultArmDeploymentCollection(ArmDeploymentCollection armDeploymentCollection) : IArmDeploymentCollection +{ + public Task> CreateOrUpdateAsync( + WaitUntil waitUntil, + string deploymentName, + ArmDeploymentContent content, + CancellationToken cancellationToken = default) + { + return armDeploymentCollection.CreateOrUpdateAsync(waitUntil, deploymentName, content, cancellationToken); + } +} diff --git a/src/Aspire.Hosting.Azure/Provisioning/Internal/DefaultProvisioningContextProvider.cs b/src/Aspire.Hosting.Azure/Provisioning/Internal/DefaultProvisioningContextProvider.cs index 75b44e9cfbc..7970074d596 100644 --- a/src/Aspire.Hosting.Azure/Provisioning/Internal/DefaultProvisioningContextProvider.cs +++ b/src/Aspire.Hosting.Azure/Provisioning/Internal/DefaultProvisioningContextProvider.cs @@ -8,6 +8,7 @@ using System.Text.Json.Nodes; using System.Text.RegularExpressions; using Aspire.Hosting.Azure.Utils; +using Aspire.Hosting.Publishing; using Azure; using Azure.Core; using Azure.ResourceManager.Resources; @@ -28,9 +29,11 @@ internal sealed partial class DefaultProvisioningContextProvider( IArmClientProvider armClientProvider, IUserPrincipalProvider userPrincipalProvider, ITokenCredentialProvider tokenCredentialProvider, - DistributedApplicationExecutionContext distributedApplicationExecutionContext) : IProvisioningContextProvider + DistributedApplicationExecutionContext distributedApplicationExecutionContext, + IOptions publishingOptions) : IProvisioningContextProvider { private readonly AzureProvisionerOptions _options = options.Value; + private readonly PublishingOptions _publishingOptions = publishingOptions.Value; private readonly TaskCompletionSource _provisioningOptionsAvailable = new(TaskCreationOptions.RunContinuationsAsynchronously); @@ -259,6 +262,7 @@ public async Task CreateProvisioningContextAsync(JsonObject } var principal = await userPrincipalProvider.GetUserPrincipalAsync(cancellationToken).ConfigureAwait(false); + var outputPath = _publishingOptions.OutputPath is { } outputPathValue ? Path.GetFullPath(outputPathValue) : null; return new ProvisioningContext( credential, @@ -268,7 +272,9 @@ public async Task CreateProvisioningContextAsync(JsonObject tenantResource, location, principal, - userSecrets); + userSecrets, + distributedApplicationExecutionContext, + outputPath); } private string GetDefaultResourceGroupName() diff --git a/src/Aspire.Hosting.Azure/Provisioning/Internal/DefaultResourceGroupResource.cs b/src/Aspire.Hosting.Azure/Provisioning/Internal/DefaultResourceGroupResource.cs index 2eecd0442b1..5ec8d1f7e1c 100644 --- a/src/Aspire.Hosting.Azure/Provisioning/Internal/DefaultResourceGroupResource.cs +++ b/src/Aspire.Hosting.Azure/Provisioning/Internal/DefaultResourceGroupResource.cs @@ -1,11 +1,8 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using Azure; using Azure.Core; -using Azure.ResourceManager; using Azure.ResourceManager.Resources; -using Azure.ResourceManager.Resources.Models; namespace Aspire.Hosting.Azure.Provisioning.Internal; @@ -21,16 +18,4 @@ public IArmDeploymentCollection GetArmDeployments() { return new DefaultArmDeploymentCollection(resourceGroupResource.GetArmDeployments()); } - - private sealed class DefaultArmDeploymentCollection(ArmDeploymentCollection armDeploymentCollection) : IArmDeploymentCollection - { - public Task> CreateOrUpdateAsync( - WaitUntil waitUntil, - string deploymentName, - ArmDeploymentContent content, - CancellationToken cancellationToken = default) - { - return armDeploymentCollection.CreateOrUpdateAsync(waitUntil, deploymentName, content, cancellationToken); - } - } } diff --git a/src/Aspire.Hosting.Azure/Provisioning/Internal/DefaultSubscriptionResource.cs b/src/Aspire.Hosting.Azure/Provisioning/Internal/DefaultSubscriptionResource.cs index 6eb02cedd2d..6ad5d97821d 100644 --- a/src/Aspire.Hosting.Azure/Provisioning/Internal/DefaultSubscriptionResource.cs +++ b/src/Aspire.Hosting.Azure/Provisioning/Internal/DefaultSubscriptionResource.cs @@ -17,6 +17,11 @@ internal sealed class DefaultSubscriptionResource(SubscriptionResource subscript public string? DisplayName => subscriptionResource.Data.DisplayName; public Guid? TenantId => subscriptionResource.Data.TenantId; + public IArmDeploymentCollection GetArmDeployments() + { + return new DefaultArmDeploymentCollection(subscriptionResource.GetArmDeployments()); + } + public IResourceGroupCollection GetResourceGroups() { return new DefaultResourceGroupCollection(subscriptionResource.GetResourceGroups()); diff --git a/src/Aspire.Hosting.Azure/Provisioning/Internal/IProvisioningServices.cs b/src/Aspire.Hosting.Azure/Provisioning/Internal/IProvisioningServices.cs index 3578a3daf31..06e72e3cde0 100644 --- a/src/Aspire.Hosting.Azure/Provisioning/Internal/IProvisioningServices.cs +++ b/src/Aspire.Hosting.Azure/Provisioning/Internal/IProvisioningServices.cs @@ -106,6 +106,11 @@ internal interface ISubscriptionResource /// Gets resource groups collection. /// IResourceGroupCollection GetResourceGroups(); + + /// + /// Gets ARM deployments collection. + /// + IArmDeploymentCollection GetArmDeployments(); } /// diff --git a/src/Aspire.Hosting.Azure/Provisioning/Provisioners/AzureProvisioner.cs b/src/Aspire.Hosting.Azure/Provisioning/Provisioners/AzureProvisioner.cs index 99a8ea61eff..8a13f998170 100644 --- a/src/Aspire.Hosting.Azure/Provisioning/Provisioners/AzureProvisioner.cs +++ b/src/Aspire.Hosting.Azure/Provisioning/Provisioners/AzureProvisioner.cs @@ -16,7 +16,7 @@ internal sealed class AzureProvisioner( DistributedApplicationExecutionContext executionContext, IConfiguration configuration, IServiceProvider serviceProvider, - BicepProvisioner bicepProvisioner, + IBicepProvisioner bicepProvisioner, ResourceNotificationService notificationService, ResourceLoggerService loggerService, IDistributedApplicationEventing eventing, diff --git a/src/Aspire.Hosting.Azure/Provisioning/Provisioners/BicepProvisioner.cs b/src/Aspire.Hosting.Azure/Provisioning/Provisioners/BicepProvisioner.cs index 5d84aa3ad43..56d1e4576be 100644 --- a/src/Aspire.Hosting.Azure/Provisioning/Provisioners/BicepProvisioner.cs +++ b/src/Aspire.Hosting.Azure/Provisioning/Provisioners/BicepProvisioner.cs @@ -17,8 +17,9 @@ internal sealed class BicepProvisioner( ResourceNotificationService notificationService, ResourceLoggerService loggerService, IBicepCompiler bicepCompiler, - ISecretClientProvider secretClientProvider) + ISecretClientProvider secretClientProvider) : IBicepProvisioner { + /// public async Task ConfigureResourceAsync(IConfiguration configuration, AzureBicepResource resource, CancellationToken cancellationToken) { var section = configuration.GetSection($"Azure:Deployments:{resource.Name}"); @@ -98,6 +99,7 @@ await notificationService.PublishUpdateAsync(resource, state => return true; } + /// public async Task GetOrCreateResourceAsync(AzureBicepResource resource, ProvisioningContext context, CancellationToken cancellationToken) { var resourceGroup = context.ResourceGroup; @@ -124,7 +126,7 @@ await notificationService.PublishUpdateAsync(resource, state => state with ]) }).ConfigureAwait(false); - var template = resource.GetBicepTemplateFile(); + var template = resource.GetBicepTemplateFile(context.OutputPath); var path = template.Path; // GetBicepTemplateFile may have added new well-known parameters, so we need @@ -162,15 +164,28 @@ await notificationService.PublishUpdateAsync(resource, state => resourceLogger.LogInformation("Deploying {Name} to {ResourceGroup}", resource.Name, resourceGroup.Name); - var deployments = resourceGroup.GetArmDeployments(); + // Deploy-time provisioning should target the subscription scope while run-time + // provisioning should target the resource group scope. + var deployments = context.ExecutionContext.IsPublishMode + ? context.Subscription.GetArmDeployments() + : resourceGroup.GetArmDeployments(); + var deploymentName = resource.Name; - var operation = await deployments.CreateOrUpdateAsync(WaitUntil.Started, resource.Name, new ArmDeploymentContent(new(ArmDeploymentMode.Incremental) + var deploymentContent = new ArmDeploymentContent(new(ArmDeploymentMode.Incremental) { Template = BinaryData.FromString(armTemplateContents), Parameters = BinaryData.FromObjectAsJson(parameters), DebugSettingDetailLevel = "ResponseContent" - }), - cancellationToken).ConfigureAwait(false); + }); + // Only set the location for publish mode deployments + // and set the deployment name to include the resource group name + // hashed with the current Unix timestamp + if (context.ExecutionContext.IsPublishMode) + { + deploymentContent.Location = context.Location; + deploymentName = $"{resourceGroup.Name}-{DateTimeOffset.Now.ToUnixTimeSeconds()}"; + } + var operation = await deployments.CreateOrUpdateAsync(WaitUntil.Started, deploymentName, deploymentContent, cancellationToken).ConfigureAwait(false); // Resolve the deployment URL before waiting for the operation to complete var url = GetDeploymentUrl(context, resourceGroup, resource.Name); @@ -198,7 +213,10 @@ await notificationService.PublishUpdateAsync(resource, state => if (deployment.Data.Properties.ProvisioningState == ResourcesProvisioningState.Succeeded) { - template.Dispose(); + if (context.ExecutionContext.IsRunMode) + { + template.Dispose(); + } } else { @@ -206,40 +224,43 @@ await notificationService.PublishUpdateAsync(resource, state => } // e.g. { "sqlServerName": { "type": "String", "value": "" }} - var outputObj = outputs?.ToObjectFromJson(); - var az = context.UserSecrets.Prop("Azure"); - az["Tenant"] = context.Tenant.DefaultDomain; + // Populate values into user-secrets during run mode + if (context.ExecutionContext.IsRunMode) + { + var az = context.UserSecrets.Prop("Azure"); + az["Tenant"] = context.Tenant.DefaultDomain; - var resourceConfig = context.UserSecrets - .Prop("Azure") - .Prop("Deployments") - .Prop(resource.Name); + var resourceConfig = context.UserSecrets + .Prop("Azure") + .Prop("Deployments") + .Prop(resource.Name); - // Clear the entire section - resourceConfig.AsObject().Clear(); + // Clear the entire section + resourceConfig.AsObject().Clear(); - // Save the deployment id to the configuration - resourceConfig["Id"] = deployment.Id.ToString(); + // Save the deployment id to the configuration + resourceConfig["Id"] = deployment.Id.ToString(); - // Stash all parameters as a single JSON string - resourceConfig["Parameters"] = parameters.ToJsonString(); + // Stash all parameters as a single JSON string + resourceConfig["Parameters"] = parameters.ToJsonString(); - if (outputObj is not null) - { - // Same for outputs - resourceConfig["Outputs"] = outputObj.ToJsonString(); - } + if (outputObj is not null) + { + // Same for outputs + resourceConfig["Outputs"] = outputObj.ToJsonString(); + } - // Write resource scope to config for consistent checksums - if (scope is not null) - { - resourceConfig["Scope"] = scope.ToJsonString(); - } + // Write resource scope to config for consistent checksums + if (scope is not null) + { + resourceConfig["Scope"] = scope.ToJsonString(); + } - // Save the checksum to the configuration - resourceConfig["CheckSum"] = BicepUtilities.GetChecksum(resource, parameters, scope); + // Save the checksum to the configuration + resourceConfig["CheckSum"] = BicepUtilities.GetChecksum(resource, parameters, scope); + } if (outputObj is not null) { diff --git a/src/Aspire.Hosting.Azure/Provisioning/Provisioners/IBicepProvisioner.cs b/src/Aspire.Hosting.Azure/Provisioning/Provisioners/IBicepProvisioner.cs new file mode 100644 index 00000000000..567fe6e3c1a --- /dev/null +++ b/src/Aspire.Hosting.Azure/Provisioning/Provisioners/IBicepProvisioner.cs @@ -0,0 +1,30 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Extensions.Configuration; + +namespace Aspire.Hosting.Azure.Provisioning; + +/// +/// Provides functionality for provisioning Azure Bicep resources. +/// +internal interface IBicepProvisioner +{ + /// + /// Configures an Azure Bicep resource from configuration settings. + /// + /// The configuration containing Azure deployment settings. + /// The Azure Bicep resource to configure. + /// A cancellation token to cancel the operation. + /// A task that represents the asynchronous operation. The task result contains a value indicating whether the resource was successfully configured. + Task ConfigureResourceAsync(IConfiguration configuration, AzureBicepResource resource, CancellationToken cancellationToken); + + /// + /// Gets an existing resource or creates a new Azure Bicep resource. + /// + /// The Azure Bicep resource to get or create. + /// The provisioning context containing Azure subscription, resource group, and other deployment details. + /// A cancellation token to cancel the operation. + /// A task that represents the asynchronous operation. + Task GetOrCreateResourceAsync(AzureBicepResource resource, ProvisioningContext context, CancellationToken cancellationToken); +} diff --git a/src/Aspire.Hosting.Azure/Provisioning/ProvisioningContext.cs b/src/Aspire.Hosting.Azure/Provisioning/ProvisioningContext.cs index 8cee595a72e..f995fdd29c7 100644 --- a/src/Aspire.Hosting.Azure/Provisioning/ProvisioningContext.cs +++ b/src/Aspire.Hosting.Azure/Provisioning/ProvisioningContext.cs @@ -17,7 +17,9 @@ internal sealed class ProvisioningContext( ITenantResource tenant, AzureLocation location, UserPrincipal principal, - JsonObject userSecrets) + JsonObject userSecrets, + DistributedApplicationExecutionContext executionContext, + string? outputPath) { public TokenCredential Credential => credential; public IArmClient ArmClient => armClient; @@ -27,4 +29,11 @@ internal sealed class ProvisioningContext( public AzureLocation Location => location; public UserPrincipal Principal => principal; public JsonObject UserSecrets => userSecrets; -} \ No newline at end of file + public DistributedApplicationExecutionContext ExecutionContext => executionContext; + /// + /// The directory containing the Bicep template files to + /// deploy. If not specified, a temporary directory will be used + /// for run-mode provisioning. Typically set for deploy-time provisioning. + /// + public string? OutputPath => outputPath; +} diff --git a/tests/Aspire.Hosting.Azure.Tests/AzureDeployerTests.cs b/tests/Aspire.Hosting.Azure.Tests/AzureDeployerTests.cs index 59128dfceab..fea2e9b6278 100644 --- a/tests/Aspire.Hosting.Azure.Tests/AzureDeployerTests.cs +++ b/tests/Aspire.Hosting.Azure.Tests/AzureDeployerTests.cs @@ -12,6 +12,8 @@ using Aspire.Hosting.Azure.Provisioning.Internal; using Aspire.Hosting.Testing; using System.Text.Json.Nodes; +using Aspire.Hosting.Azure.Provisioning; +using Microsoft.Extensions.Configuration; namespace Aspire.Hosting.Azure.Tests; @@ -126,6 +128,7 @@ private static void ConfigureTestServices(IDistributedApplicationTestingBuilder builder.Services.AddSingleton(interactionService); builder.Services.AddSingleton(); builder.Services.AddSingleton(); + builder.Services.AddSingleton(); } private sealed class NoOpUserSecretsManager : IUserSecretsManager @@ -134,4 +137,17 @@ private sealed class NoOpUserSecretsManager : IUserSecretsManager public Task SaveUserSecretsAsync(JsonObject userSecrets, CancellationToken cancellationToken = default) => Task.CompletedTask; } + + private sealed class NoOpBicepProvisioner : IBicepProvisioner + { + public Task ConfigureResourceAsync(IConfiguration configuration, AzureBicepResource resource, CancellationToken cancellationToken) + { + return Task.FromResult(true); + } + + public Task GetOrCreateResourceAsync(AzureBicepResource resource, ProvisioningContext context, CancellationToken cancellationToken) + { + return Task.CompletedTask; + } + } } diff --git a/tests/Aspire.Hosting.Azure.Tests/DefaultProvisioningContextProviderTests.cs b/tests/Aspire.Hosting.Azure.Tests/DefaultProvisioningContextProviderTests.cs index 97eede45522..9f68614e9c8 100644 --- a/tests/Aspire.Hosting.Azure.Tests/DefaultProvisioningContextProviderTests.cs +++ b/tests/Aspire.Hosting.Azure.Tests/DefaultProvisioningContextProviderTests.cs @@ -20,6 +20,7 @@ public async Task CreateProvisioningContextAsync_ReturnsValidContext() { // Arrange var options = ProvisioningTestHelpers.CreateOptions(); + var publishingOptions = ProvisioningTestHelpers.CreatePublishingOptions(); var environment = ProvisioningTestHelpers.CreateEnvironment(); var logger = ProvisioningTestHelpers.CreateLogger(); var armClientProvider = ProvisioningTestHelpers.CreateArmClientProvider(); @@ -35,7 +36,8 @@ public async Task CreateProvisioningContextAsync_ReturnsValidContext() armClientProvider, userPrincipalProvider, tokenCredentialProvider, - new DistributedApplicationExecutionContext(DistributedApplicationOperation.Run)); + new DistributedApplicationExecutionContext(DistributedApplicationOperation.Run), + publishingOptions); // Act var context = await provider.CreateProvisioningContextAsync(userSecrets); @@ -58,6 +60,7 @@ public async Task CreateProvisioningContextAsync_ThrowsWhenSubscriptionIdMissing { // Arrange var options = ProvisioningTestHelpers.CreateOptions(subscriptionId: null); + var publishingOptions = ProvisioningTestHelpers.CreatePublishingOptions(); var environment = ProvisioningTestHelpers.CreateEnvironment(); var logger = ProvisioningTestHelpers.CreateLogger(); var armClientProvider = ProvisioningTestHelpers.CreateArmClientProvider(); @@ -73,7 +76,8 @@ public async Task CreateProvisioningContextAsync_ThrowsWhenSubscriptionIdMissing armClientProvider, userPrincipalProvider, tokenCredentialProvider, - new DistributedApplicationExecutionContext(DistributedApplicationOperation.Run)); + new DistributedApplicationExecutionContext(DistributedApplicationOperation.Run), + publishingOptions); // Act & Assert var exception = await Assert.ThrowsAsync( @@ -86,6 +90,7 @@ public async Task CreateProvisioningContextAsync_ThrowsWhenLocationMissing() { // Arrange var options = ProvisioningTestHelpers.CreateOptions(location: null); + var publishingOptions = ProvisioningTestHelpers.CreatePublishingOptions(); var environment = ProvisioningTestHelpers.CreateEnvironment(); var logger = ProvisioningTestHelpers.CreateLogger(); var armClientProvider = ProvisioningTestHelpers.CreateArmClientProvider(); @@ -101,7 +106,8 @@ public async Task CreateProvisioningContextAsync_ThrowsWhenLocationMissing() armClientProvider, userPrincipalProvider, tokenCredentialProvider, - new DistributedApplicationExecutionContext(DistributedApplicationOperation.Run)); + new DistributedApplicationExecutionContext(DistributedApplicationOperation.Run), + publishingOptions); // Act & Assert var exception = await Assert.ThrowsAsync( @@ -114,6 +120,7 @@ public async Task CreateProvisioningContextAsync_GeneratesResourceGroupNameWhenN { // Arrange var options = ProvisioningTestHelpers.CreateOptions(resourceGroup: null); + var publishingOptions = ProvisioningTestHelpers.CreatePublishingOptions(); var environment = ProvisioningTestHelpers.CreateEnvironment(); var logger = ProvisioningTestHelpers.CreateLogger(); var armClientProvider = ProvisioningTestHelpers.CreateArmClientProvider(); @@ -129,7 +136,8 @@ public async Task CreateProvisioningContextAsync_GeneratesResourceGroupNameWhenN armClientProvider, userPrincipalProvider, tokenCredentialProvider, - new DistributedApplicationExecutionContext(DistributedApplicationOperation.Run)); + new DistributedApplicationExecutionContext(DistributedApplicationOperation.Run), + publishingOptions); // Act var context = await provider.CreateProvisioningContextAsync(userSecrets); @@ -150,6 +158,7 @@ public async Task CreateProvisioningContextAsync_UsesProvidedResourceGroupName() // Arrange var resourceGroupName = "my-custom-rg"; var options = ProvisioningTestHelpers.CreateOptions(resourceGroup: resourceGroupName); + var publishingOptions = ProvisioningTestHelpers.CreatePublishingOptions(); var environment = ProvisioningTestHelpers.CreateEnvironment(); var logger = ProvisioningTestHelpers.CreateLogger(); var armClientProvider = ProvisioningTestHelpers.CreateArmClientProvider(); @@ -165,7 +174,8 @@ public async Task CreateProvisioningContextAsync_UsesProvidedResourceGroupName() armClientProvider, userPrincipalProvider, tokenCredentialProvider, - new DistributedApplicationExecutionContext(DistributedApplicationOperation.Run)); + new DistributedApplicationExecutionContext(DistributedApplicationOperation.Run), + publishingOptions); // Act var context = await provider.CreateProvisioningContextAsync(userSecrets); @@ -180,6 +190,7 @@ public async Task CreateProvisioningContextAsync_RetrievesUserPrincipal() { // Arrange var options = ProvisioningTestHelpers.CreateOptions(); + var publishingOptions = ProvisioningTestHelpers.CreatePublishingOptions(); var environment = ProvisioningTestHelpers.CreateEnvironment(); var logger = ProvisioningTestHelpers.CreateLogger(); var armClientProvider = ProvisioningTestHelpers.CreateArmClientProvider(); @@ -195,7 +206,8 @@ public async Task CreateProvisioningContextAsync_RetrievesUserPrincipal() armClientProvider, userPrincipalProvider, tokenCredentialProvider, - new DistributedApplicationExecutionContext(DistributedApplicationOperation.Run)); + new DistributedApplicationExecutionContext(DistributedApplicationOperation.Run), + publishingOptions); // Act var context = await provider.CreateProvisioningContextAsync(userSecrets); @@ -211,6 +223,7 @@ public async Task CreateProvisioningContextAsync_SetsCorrectTenant() { // Arrange var options = ProvisioningTestHelpers.CreateOptions(); + var publishingOptions = ProvisioningTestHelpers.CreatePublishingOptions(); var environment = ProvisioningTestHelpers.CreateEnvironment(); var logger = ProvisioningTestHelpers.CreateLogger(); var armClientProvider = ProvisioningTestHelpers.CreateArmClientProvider(); @@ -226,7 +239,8 @@ public async Task CreateProvisioningContextAsync_SetsCorrectTenant() armClientProvider, userPrincipalProvider, tokenCredentialProvider, - new DistributedApplicationExecutionContext(DistributedApplicationOperation.Run)); + new DistributedApplicationExecutionContext(DistributedApplicationOperation.Run), + publishingOptions); // Act var context = await provider.CreateProvisioningContextAsync(userSecrets); @@ -243,6 +257,7 @@ public async Task CreateProvisioningContextAsync_PromptsIfNoOptions() // Arrange var testInteractionService = new TestInteractionService(); var options = ProvisioningTestHelpers.CreateOptions(null, null, null); + var publishingOptions = ProvisioningTestHelpers.CreatePublishingOptions(); var environment = ProvisioningTestHelpers.CreateEnvironment(); var logger = ProvisioningTestHelpers.CreateLogger(); var armClientProvider = ProvisioningTestHelpers.CreateArmClientProvider(); @@ -258,7 +273,8 @@ public async Task CreateProvisioningContextAsync_PromptsIfNoOptions() armClientProvider, userPrincipalProvider, tokenCredentialProvider, - new DistributedApplicationExecutionContext(DistributedApplicationOperation.Run)); + new DistributedApplicationExecutionContext(DistributedApplicationOperation.Run), + publishingOptions); // Act var createTask = provider.CreateProvisioningContextAsync(userSecrets); @@ -317,6 +333,7 @@ public async Task CreateProvisioningContextAsync_Prompt_ValidatesSubAndResourceG { var testInteractionService = new TestInteractionService(); var options = ProvisioningTestHelpers.CreateOptions(null, null, null); + var publishingOptions = ProvisioningTestHelpers.CreatePublishingOptions(); var environment = ProvisioningTestHelpers.CreateEnvironment(); var logger = ProvisioningTestHelpers.CreateLogger(); var armClientProvider = ProvisioningTestHelpers.CreateArmClientProvider(); @@ -332,7 +349,8 @@ public async Task CreateProvisioningContextAsync_Prompt_ValidatesSubAndResourceG armClientProvider, userPrincipalProvider, tokenCredentialProvider, - new DistributedApplicationExecutionContext(DistributedApplicationOperation.Run)); + new DistributedApplicationExecutionContext(DistributedApplicationOperation.Run), + publishingOptions); var createTask = provider.CreateProvisioningContextAsync(userSecrets); diff --git a/tests/Aspire.Hosting.Azure.Tests/ProvisioningTestHelpers.cs b/tests/Aspire.Hosting.Azure.Tests/ProvisioningTestHelpers.cs index b49c6020172..5d56cf27b13 100644 --- a/tests/Aspire.Hosting.Azure.Tests/ProvisioningTestHelpers.cs +++ b/tests/Aspire.Hosting.Azure.Tests/ProvisioningTestHelpers.cs @@ -7,6 +7,7 @@ using System.Text.Json.Nodes; using Aspire.Hosting.Azure.Provisioning; using Aspire.Hosting.Azure.Provisioning.Internal; +using Aspire.Hosting.Publishing; using Azure; using Azure.Core; using Azure.ResourceManager; @@ -37,7 +38,9 @@ public static ProvisioningContext CreateTestProvisioningContext( ITenantResource? tenant = null, AzureLocation? location = null, UserPrincipal? principal = null, - JsonObject? userSecrets = null) + JsonObject? userSecrets = null, + DistributedApplicationExecutionContext? executionContext = null, + string? outputPath = null) { return new ProvisioningContext( credential ?? new TestTokenCredential(), @@ -47,7 +50,9 @@ public static ProvisioningContext CreateTestProvisioningContext( tenant ?? new TestTenantResource(), location ?? AzureLocation.WestUS2, principal ?? new UserPrincipal(Guid.NewGuid(), "test@example.com"), - userSecrets ?? new JsonObject()); + userSecrets ?? new JsonObject(), + executionContext ?? new DistributedApplicationExecutionContext(DistributedApplicationOperation.Run), + outputPath); } // Factory methods for test implementations of provisioning services interfaces @@ -76,6 +81,16 @@ public static IOptions CreateOptions( return Options.Create(options); } + public static IOptions CreatePublishingOptions( + string? outputPath = null) + { + var options = new PublishingOptions + { + OutputPath = outputPath, + }; + return Options.Create(options); + } + /// /// Creates a test host environment. /// @@ -169,6 +184,11 @@ internal sealed class TestSubscriptionResource : ISubscriptionResource public string? DisplayName { get; } = "Test Subscription"; public Guid? TenantId { get; } = Guid.Parse("87654321-4321-4321-4321-210987654321"); + public IArmDeploymentCollection GetArmDeployments() + { + return new TestArmDeploymentCollection(); + } + public IResourceGroupCollection GetResourceGroups() { return new TestResourceGroupCollection(); diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureEnvironmentResourceTests.AzurePublishingContext_IgnoresAzureBicepResourcesWithIgnoreAnnotation.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureEnvironmentResourceTests.AzurePublishingContext_IgnoresAzureBicepResourcesWithIgnoreAnnotation.verified.bicep index 29020972b6d..9abb39e9ff8 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureEnvironmentResourceTests.AzurePublishingContext_IgnoresAzureBicepResourcesWithIgnoreAnnotation.verified.bicep +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureEnvironmentResourceTests.AzurePublishingContext_IgnoresAzureBicepResourcesWithIgnoreAnnotation.verified.bicep @@ -26,6 +26,6 @@ module included_storage_roles 'included-storage-roles/included-storage-roles.bic location: location included_storage_outputs_name: included_storage.outputs.name principalType: '' - principalId: '' + principalId: principalId } } \ No newline at end of file