diff --git a/src/Aspire.Hosting.Azure/AzurePublisher.cs b/src/Aspire.Hosting.Azure/AzurePublisher.cs index 4ff11d035fe..0c3c9044c00 100644 --- a/src/Aspire.Hosting.Azure/AzurePublisher.cs +++ b/src/Aspire.Hosting.Azure/AzurePublisher.cs @@ -4,16 +4,28 @@ using System.Diagnostics.CodeAnalysis; using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.Publishing; -using Azure.Provisioning; -using Azure.Provisioning.Expressions; -using Azure.Provisioning.Primitives; -using Azure.Provisioning.Resources; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; namespace Aspire.Hosting.Azure; +/// +/// Represents a publisher for deploying distributed application models to Azure using Bicep templates. +/// +/// +/// This class is responsible for processing a distributed application model, generating Bicep templates, +/// and configuring Azure infrastructure for deployment. It supports parameter resolution, resource grouping, +/// and output propagation for Azure resources. +/// +/// +/// Example usage: +/// +/// var publisher = new AzurePublisher("myPublisher", optionsMonitor, provisioningOptions, logger); +/// await publisher.PublishAsync(model, cancellationToken); +/// +/// +/// [Experimental("ASPIREAZURE001", UrlFormat = "https://aka.ms/dotnet/aspire/diagnostics#{0}")] internal sealed class AzurePublisher( [ServiceKey] string name, @@ -21,272 +33,21 @@ internal sealed class AzurePublisher( IOptions provisioningOptions, ILogger logger) : IDistributedApplicationPublisher { - private AzureProvisioningOptions ProvisioningOptions => provisioningOptions.Value; - - public Task PublishAsync(DistributedApplicationModel model, CancellationToken cancellationToken) + /// + /// Publishes the specified distributed application model to Azure using Bicep templates. + /// + /// The distributed application model to publish. + /// A token to monitor for cancellation requests. + /// A task that represents the asynchronous publish operation. + public async Task PublishAsync(DistributedApplicationModel model, CancellationToken cancellationToken) { var publisherOptions = options.Get(name); - var infra = new Infrastructure - { - TargetScope = DeploymentScope.Subscription - }; - - var environmentParam = new ProvisioningParameter("environmentName", typeof(string)); - infra.Add(environmentParam); - - var locationParam = new ProvisioningParameter("location", typeof(string)); - infra.Add(locationParam); - - var principalId = new ProvisioningParameter("principalId", typeof(string)); - infra.Add(principalId); - - var tags = new ProvisioningVariable("tags", typeof(object)) - { - Value = new BicepDictionary - { - ["aspire-env-name"] = environmentParam - } - }; - - // REVIEW: Do we want people to be able to change this - var rg = new ResourceGroup("rg") - { - Name = BicepFunction.Interpolate($"rg-{environmentParam}"), - Location = locationParam, - Tags = tags - }; - var outputDirectory = new DirectoryInfo(publisherOptions.OutputPath!); - outputDirectory.Create(); - // Process the resources in the model and create a module for each one - var moduleMap = new Dictionary(); - - foreach (var resource in model.Resources.OfType()) - { - var file = resource.GetBicepTemplateFile(); - - var moduleDirectory = outputDirectory.CreateSubdirectory(resource.Name); - - var modulePath = Path.Combine(moduleDirectory.FullName, $"{resource.Name}.bicep"); - - File.Copy(file.Path, modulePath, true); - - var identifier = Infrastructure.NormalizeBicepIdentifier(resource.Name); - - var module = new ModuleImport(identifier, $"{resource.Name}/{resource.Name}.bicep") - { - Name = resource.Name - }; - - moduleMap[resource] = module; - } - - // Resolve parameters *after* writing the modules to disk - // this is because some parameters are added in ConfigureInfrastructure callbacks - var parameterMap = new Dictionary(); - - foreach (var resource in model.Resources.OfType()) - { - foreach (var parameter in resource.Parameters) - { - Visit(parameter.Value, v => - { - if (v is ParameterResource p && !parameterMap.ContainsKey(p)) - { - var pid = Infrastructure.NormalizeBicepIdentifier(p.Name); - - var pp = new ProvisioningParameter(pid, typeof(string)) - { - IsSecure = p.Secret - }; - - if (!p.Secret && p.Default is not null) - { - pp.Value = p.Value; - } - - // Map the parameter to the Bicep parameter - parameterMap[p] = pp; - } - }); - } - } - - static BicepValue GetOutputs(ModuleImport module, string outputName) => - new MemberExpression(new MemberExpression(new IdentifierExpression(module.BicepIdentifier), "outputs"), outputName); - - BicepFormatString EvalExpr(ReferenceExpression expr) - { - var args = new object[expr.ValueProviders.Count]; - - for (var i = 0; i < expr.ValueProviders.Count; i++) - { - args[i] = Eval(expr.ValueProviders[i]); - } - - return new BicepFormatString(expr.Format, args); - } - - object Eval(object? value) => value switch - { - BicepOutputReference b => GetOutputs(moduleMap[b.Resource], b.Name), - ParameterResource p => parameterMap[p], - ConnectionStringReference r => Eval(r.Resource.ConnectionStringExpression), - IResourceWithConnectionString cs => Eval(cs.ConnectionStringExpression), - ReferenceExpression re => EvalExpr(re), - string s => s, - _ => "" - }; - - static BicepValue ResolveValue(object val) - { - return val switch - { - BicepValue s => s, - string s => s, - ProvisioningParameter p => p, - BicepFormatString fs => BicepFunction2.Interpolate(fs), - _ => throw new NotSupportedException("Unsupported value type " + val.GetType()) - }; - } - - foreach (var resource in model.Resources.OfType()) - { - BicepValue scope = resource.Scope?.ResourceGroup switch - { - // resourceGroup(rgName) - string rgName => new FunctionCallExpression(new IdentifierExpression("resourceGroup"), new StringLiteralExpression(rgName)), - ParameterResource p => parameterMap[p], - _ => new IdentifierExpression(rg.BicepIdentifier) - }; - - var module = moduleMap[resource]; - module.Scope = scope; - module.Parameters.Add("location", locationParam); - - foreach (var parameter in resource.Parameters) - { - // TODO: There are a set of known parameter names that we may not be able to resolve. - // This is from earlier versions of aspire where infra was split across - // azd and aspire. Once the infra moves to aspire, we can throw for - // unresolved "known parameters". - - if (parameter.Key == AzureBicepResource.KnownParameters.UserPrincipalId && parameter.Value is null) - { - module.Parameters.Add(parameter.Key, principalId); - continue; - } - - var value = ResolveValue(Eval(parameter.Value)); - - module.Parameters.Add(parameter.Key, value); - } - } - - var outputs = new Dictionary(); - - // Now find all resources that have deployment targets that are bicep modules - foreach (var resource in model.Resources) - { - if (resource.TryGetLastAnnotation(out var targetAnnotation) && - targetAnnotation.DeploymentTarget is AzureBicepResource br) - { - var moduleDirectory = outputDirectory.CreateSubdirectory(resource.Name); - - var modulePath = Path.Combine(moduleDirectory.FullName, $"{resource.Name}.bicep"); - - var file = br.GetBicepTemplateFile(); - - File.Copy(file.Path, modulePath, true); - - // TODO: Resolve parameters for the module and - // handle flowing outputs from other modules - - foreach (var parameter in br.Parameters) - { - Visit(parameter.Value, v => - { - if (v is BicepOutputReference bo) - { - // Any bicep output reference needs to be propagated to the top level - outputs[bo.ValueExpression] = bo; - } - }); - } - } - } - - // Add parameters to the infrastructure - foreach (var (_, pp) in parameterMap) - { - infra.Add(pp); - } - - // Add the parameters to the infrastructure - infra.Add(tags); - - // Add the resource group to the infrastructure - infra.Add(rg); - - // Add the modules to the infrastructure - foreach (var (_, module) in moduleMap) - { - // Add the module to the infrastructure - infra.Add(module); - } - - // Add the outputs to the infrastructure - foreach (var (_, output) in outputs) - { - var module = moduleMap[output.Resource]; - - var identifier = Infrastructure.NormalizeBicepIdentifier($"{output.Resource.Name}_{output.Name}"); - - var bicepOutput = new ProvisioningOutput(identifier, typeof(string)) - { - Value = GetOutputs(module, output.Name) - }; - - infra.Add(bicepOutput); - } - - SaveToDisk(outputDirectory.FullName, infra); - - return Task.CompletedTask; - } - - private static void Visit(object? value, Action visitor) => - Visit(value, visitor, []); - - private static void Visit(object? value, Action visitor, HashSet visited) - { - if (value is null || !visited.Add(value)) - { - return; - } - - visitor(value); - - if (value is IValueWithReferences vwr) - { - foreach (var reference in vwr.References) - { - Visit(reference, visitor, visited); - } - } - } - - private void SaveToDisk(string outputDirectoryPath, Infrastructure infrastructure) - { - var plan = infrastructure.Build(ProvisioningOptions.ProvisioningBuildOptions); - var compiledBicep = plan.Compile().First(); - - logger.LogDebug("Writing Bicep module {BicepName}.bicep to {TargetPath}", infrastructure.BicepName, outputDirectoryPath); + var context = new AzurePublishingContext(publisherOptions, provisioningOptions.Value, logger); - var bicepPath = Path.Combine(outputDirectoryPath, $"{infrastructure.BicepName}.bicep"); - File.WriteAllText(bicepPath, compiledBicep.Value); + await context.WriteModelAsync(model, cancellationToken).ConfigureAwait(false); } } diff --git a/src/Aspire.Hosting.Azure/AzurePublishingContext.cs b/src/Aspire.Hosting.Azure/AzurePublishingContext.cs new file mode 100644 index 00000000000..7680b6b5492 --- /dev/null +++ b/src/Aspire.Hosting.Azure/AzurePublishingContext.cs @@ -0,0 +1,302 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics.CodeAnalysis; +using Aspire.Hosting.ApplicationModel; +using Azure.Provisioning; +using Azure.Provisioning.Expressions; +using Azure.Provisioning.Primitives; +using Azure.Provisioning.Resources; +using Microsoft.Extensions.Logging; + +namespace Aspire.Hosting.Azure; + +/// +/// Represents a context for publishing Azure bicep templates for a distributed application. +/// +/// +/// This context facilitates the generation of bicep templates using the provided application model, +/// publisher options, and execution context. It handles resource configuration and ensures +/// that the bicep template is created in the specified output path. +/// +[Experimental("ASPIREAZURE001", UrlFormat = "https://aka.ms/dotnet/aspire/diagnostics#{0}")] +public sealed class AzurePublishingContext( + AzurePublisherOptions publisherOptions, + AzureProvisioningOptions provisioningOptions, + ILogger logger) +{ + private ILogger Logger => logger; + private AzurePublisherOptions PublisherOptions => publisherOptions; + + /// + /// Gets the main.bicep infrastructure for the distributed application. + /// + public Infrastructure MainInfrastructure = new() + { + TargetScope = DeploymentScope.Subscription + }; + + /// + /// Writes the specified distributed application model to the output path using Bicep templates. + /// + /// The distributed application model to write to the output path. + /// A token to monitor for cancellation requests. + /// A task that represents the async operation. + public async Task WriteModelAsync(DistributedApplicationModel model, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(model); + ArgumentNullException.ThrowIfNull(PublisherOptions.OutputPath); + + if (model.Resources.Count == 0) + { + Logger.LogInformation("No resources found in the model"); + return; + } + + await WriteAzureArtifactsOutputAsync(model, cancellationToken).ConfigureAwait(false); + + await SaveToDiskAsync(PublisherOptions.OutputPath).ConfigureAwait(false); + } + + private Task WriteAzureArtifactsOutputAsync(DistributedApplicationModel model, CancellationToken _) + { + var outputDirectory = new DirectoryInfo(PublisherOptions.OutputPath!); + if (!outputDirectory.Exists) + { + outputDirectory.Create(); + } + + var environmentParam = new ProvisioningParameter("environmentName", typeof(string)); + MainInfrastructure.Add(environmentParam); + + var locationParam = new ProvisioningParameter("location", typeof(string)); + MainInfrastructure.Add(locationParam); + + var principalId = new ProvisioningParameter("principalId", typeof(string)); + MainInfrastructure.Add(principalId); + + var tags = new ProvisioningVariable("tags", typeof(object)) + { + Value = new BicepDictionary + { + ["aspire-env-name"] = environmentParam + } + }; + + var rg = new ResourceGroup("rg") + { + Name = BicepFunction.Interpolate($"rg-{environmentParam}"), + Location = locationParam, + Tags = tags + }; + + var moduleMap = new Dictionary(); + + foreach (var resource in model.Resources.OfType()) + { + var file = resource.GetBicepTemplateFile(); + + var moduleDirectory = outputDirectory.CreateSubdirectory(resource.Name); + + var modulePath = Path.Combine(moduleDirectory.FullName, $"{resource.Name}.bicep"); + + File.Copy(file.Path, modulePath, true); + + var identifier = Infrastructure.NormalizeBicepIdentifier(resource.Name); + + var module = new ModuleImport(identifier, $"{resource.Name}/{resource.Name}.bicep") + { + Name = resource.Name + }; + + moduleMap[resource] = module; + } + + var parameterMap = new Dictionary(); + + foreach (var resource in model.Resources.OfType()) + { + foreach (var parameter in resource.Parameters) + { + Visit(parameter.Value, v => + { + if (v is ParameterResource p && !parameterMap.ContainsKey(p)) + { + var pid = Infrastructure.NormalizeBicepIdentifier(p.Name); + + var pp = new ProvisioningParameter(pid, typeof(string)) + { + IsSecure = p.Secret + }; + + if (!p.Secret && p.Default is not null) + { + pp.Value = p.Value; + } + + parameterMap[p] = pp; + } + }); + } + } + + static BicepValue GetOutputs(ModuleImport module, string outputName) => + new MemberExpression(new MemberExpression(new IdentifierExpression(module.BicepIdentifier), "outputs"), outputName); + + BicepFormatString EvalExpr(ReferenceExpression expr) + { + var args = new object[expr.ValueProviders.Count]; + + for (var i = 0; i < expr.ValueProviders.Count; i++) + { + args[i] = Eval(expr.ValueProviders[i]); + } + + return new BicepFormatString(expr.Format, args); + } + + object Eval(object? value) => value switch + { + BicepOutputReference b => GetOutputs(moduleMap[b.Resource], b.Name), + ParameterResource p => parameterMap[p], + ConnectionStringReference r => Eval(r.Resource.ConnectionStringExpression), + IResourceWithConnectionString cs => Eval(cs.ConnectionStringExpression), + ReferenceExpression re => EvalExpr(re), + string s => s, + _ => "" + }; + + static BicepValue ResolveValue(object val) + { + return val switch + { + BicepValue s => s, + string s => s, + ProvisioningParameter p => p, + BicepFormatString fs => BicepFunction2.Interpolate(fs), + _ => throw new NotSupportedException("Unsupported value type " + val.GetType()) + }; + } + + foreach (var resource in model.Resources.OfType()) + { + BicepValue scope = resource.Scope?.ResourceGroup switch + { + string rgName => new FunctionCallExpression(new IdentifierExpression("resourceGroup"), new StringLiteralExpression(rgName)), + ParameterResource p => parameterMap[p], + _ => new IdentifierExpression(rg.BicepIdentifier) + }; + + var module = moduleMap[resource]; + module.Scope = scope; + module.Parameters.Add("location", locationParam); + + foreach (var parameter in resource.Parameters) + { + if (parameter.Key == AzureBicepResource.KnownParameters.UserPrincipalId && parameter.Value is null) + { + module.Parameters.Add(parameter.Key, principalId); + continue; + } + + var value = ResolveValue(Eval(parameter.Value)); + + module.Parameters.Add(parameter.Key, value); + } + } + + var outputs = new Dictionary(); + + foreach (var resource in model.Resources) + { + if (resource.TryGetLastAnnotation(out var targetAnnotation) && + targetAnnotation.DeploymentTarget is AzureBicepResource br) + { + var moduleDirectory = outputDirectory.CreateSubdirectory(resource.Name); + + var modulePath = Path.Combine(moduleDirectory.FullName, $"{resource.Name}.bicep"); + + var file = br.GetBicepTemplateFile(); + + File.Copy(file.Path, modulePath, true); + + foreach (var parameter in br.Parameters) + { + Visit(parameter.Value, v => + { + if (v is BicepOutputReference bo) + { + outputs[bo.ValueExpression] = bo; + } + }); + } + } + } + + foreach (var (_, pp) in parameterMap) + { + MainInfrastructure.Add(pp); + } + + MainInfrastructure.Add(tags); + MainInfrastructure.Add(rg); + + foreach (var (_, module) in moduleMap) + { + MainInfrastructure.Add(module); + } + + foreach (var (_, output) in outputs) + { + var module = moduleMap[output.Resource]; + + var identifier = Infrastructure.NormalizeBicepIdentifier($"{output.Resource.Name}_{output.Name}"); + + var bicepOutput = new ProvisioningOutput(identifier, typeof(string)) + { + Value = GetOutputs(module, output.Name) + }; + + MainInfrastructure.Add(bicepOutput); + } + + return Task.CompletedTask; + } + + private static void Visit(object? value, Action visitor) => + Visit(value, visitor, new HashSet()); + + private static void Visit(object? value, Action visitor, HashSet visited) + { + if (value is null || !visited.Add(value)) + { + return; + } + + visitor(value); + + if (value is IValueWithReferences vwr) + { + foreach (var reference in vwr.References) + { + Visit(reference, visitor, visited); + } + } + } + + /// + /// Saves the compiled Bicep template to disk. + /// + /// The path to the output directory where the Bicep template will be saved. + /// A task that represents the asynchronous save operation. + private async Task SaveToDiskAsync(string outputDirectoryPath) + { + var plan = MainInfrastructure.Build(provisioningOptions.ProvisioningBuildOptions); + var compiledBicep = plan.Compile().First(); + + logger.LogDebug("Writing Bicep module {BicepName}.bicep to {TargetPath}", MainInfrastructure.BicepName, outputDirectoryPath); + + var bicepPath = Path.Combine(outputDirectoryPath, $"{MainInfrastructure.BicepName}.bicep"); + await File.WriteAllTextAsync(bicepPath, compiledBicep.Value).ConfigureAwait(false); + } +} diff --git a/tests/Aspire.Hosting.Azure.Tests/AzurePublisherTests.cs b/tests/Aspire.Hosting.Azure.Tests/AzurePublisherTests.cs index ca3edff650c..bdaa3dfd41b 100644 --- a/tests/Aspire.Hosting.Azure.Tests/AzurePublisherTests.cs +++ b/tests/Aspire.Hosting.Azure.Tests/AzurePublisherTests.cs @@ -17,8 +17,10 @@ namespace Aspire.Hosting.Azure.Tests; public class AzurePublisherTests(ITestOutputHelper output) { - [Fact] - public async Task PublishAsync_GeneratesMainBicep() + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task PublishAsync_GeneratesMainBicep(bool useContext) { using var tempDirectory = new TempDirectory(); using var tempDir = new TempDirectory(); @@ -72,12 +74,26 @@ public async Task PublishAsync_GeneratesMainBicep() await ExecuteBeforeStartHooksAsync(app, default); - var publisher = new AzurePublisher("azure", - options, - provisionerOptions, - NullLogger.Instance); + if (useContext) + { + // tests the public AzurePublishingContext API + var context = new AzurePublishingContext( + options.CurrentValue, + provisionerOptions.Value, + NullLogger.Instance); - await publisher.PublishAsync(model, default); + await context.WriteModelAsync(model, default); + } + else + { + // tests via the internal Publisher object + var publisher = new AzurePublisher("azure", + options, + provisionerOptions, + NullLogger.Instance); + + await publisher.PublishAsync(model, default); + } Assert.True(File.Exists(Path.Combine(tempDir.Path, "main.bicep")));