// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
#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 ASPIREPIPELINES003 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
#pragma warning disable ASPIREPIPELINES004 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
using System.Diagnostics.CodeAnalysis;
using Aspire.Hosting.ApplicationModel;
using Aspire.Hosting.Azure.Provisioning;
using Aspire.Hosting.Azure.Provisioning.Internal;
using Aspire.Hosting.Pipelines;
using Azure;
using Azure.Core;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace Aspire.Hosting.Azure;
/// <summary>
/// Represents the root Azure deployment target for an Aspire application.
/// Manages deployment parameters and context for Azure resources.
/// </summary>
[Experimental("ASPIREAZURE001", UrlFormat = "https://aka.ms/aspire/diagnostics#{0}")]
public sealed class AzureEnvironmentResource : Resource
{
/// <summary>
/// The name of the step that creates the provisioning context.
/// </summary>
internal const string CreateProvisioningContextStepName = "create-provisioning-context";
/// <summary>
/// The name of the step that prepares Azure resources (e.g. materializes role-assignment
/// resources) so that downstream steps can reference them.
/// </summary>
public const string PrepareResourcesStepName = "azure-prepare-resources";
/// <summary>
/// The name of the step that provisions Azure infrastructure resources.
/// </summary>
public const string ProvisionInfrastructureStepName = "provision-azure-bicep-resources";
/// <summary>
/// Gets or sets the Azure location that the resources will be deployed to.
/// </summary>
public ParameterResource Location { get; set; }
/// <summary>
/// Gets or sets the Azure resource group name that the resources will be deployed to.
/// </summary>
public ParameterResource ResourceGroupName { get; set; }
/// <summary>
/// Gets or sets the Azure principal ID that will be used to deploy the resources.
/// </summary>
public ParameterResource PrincipalId { get; set; }
/// <summary>
/// Gets the task completion source for the provisioning context.
/// Consumers should await ProvisioningContextTask.Task to get the provisioning context.
/// </summary>
internal TaskCompletionSource<ProvisioningContext> ProvisioningContextTask { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
/// <summary>
/// Initializes a new instance of the <see cref="AzureEnvironmentResource"/> class.
/// </summary>
/// <param name="name">The name of the Azure environment resource.</param>
/// <param name="location">The Azure location that the resources will be deployed to.</param>
/// <param name="resourceGroupName">The Azure resource group name that the resources will be deployed to.</param>
/// <param name="principalId">The Azure principal ID that will be used to deploy the resources.</param>
/// <exception cref="ArgumentNullException">Thrown when the name is null or empty.</exception>
/// <exception cref="ArgumentException">Thrown when the name is invalid.</exception>
public AzureEnvironmentResource(string name, ParameterResource location, ParameterResource resourceGroupName, ParameterResource principalId) : base(name)
{
Annotations.Add(new PipelineStepAnnotation((factoryContext) =>
{
var steps = new List<PipelineStep>();
var prepareResourcesStep = new PipelineStep
{
Name = PrepareResourcesStepName,
Description = "Prepares the Azure resources.",
Action = static async context =>
{
var preparer = context.Services.GetRequiredService<AzureResourcePreparer>();
await preparer.PrepareResourcesAsync(context.Model, context.CancellationToken).ConfigureAwait(false);
},
RequiredBySteps = [WellKnownPipelineSteps.BeforeStart]
};
steps.Add(prepareResourcesStep);
var publishStep = new PipelineStep
{
Name = $"publish-{Name}",
Description = $"Publishes the Azure environment configuration for {Name}.",
Action = ctx => PublishAsync(ctx),
RequiredBySteps = [WellKnownPipelineSteps.Publish],
DependsOnSteps = [WellKnownPipelineSteps.PublishPrereq]
};
steps.Add(publishStep);
var validateStep = new PipelineStep
{
Name = "validate-azure-login",
Description = "Validates Azure CLI authentication before deployment.",
Action = ctx => ValidateAzureLoginAsync(ctx),
RequiredBySteps = [WellKnownPipelineSteps.Deploy],
DependsOnSteps = [WellKnownPipelineSteps.DeployPrereq]
};
steps.Add(validateStep);
var createContextStep = new PipelineStep
{
Name = CreateProvisioningContextStepName,
Description = "Creates the Azure provisioning context for infrastructure deployment.",
Action = async ctx =>
{
var provisioningContextProvider = ctx.Services.GetRequiredService<IProvisioningContextProvider>();
var provisioningContext = await provisioningContextProvider.CreateProvisioningContextAsync(ctx.CancellationToken).ConfigureAwait(false);
ProvisioningContextTask.TrySetResult(provisioningContext);
// Add Azure deployment information to the pipeline summary
AddToPipelineSummary(ctx, provisioningContext);
},
RequiredBySteps = [WellKnownPipelineSteps.Deploy],
DependsOnSteps = [WellKnownPipelineSteps.DeployPrereq]
};
steps.Add(createContextStep);
createContextStep.DependsOn(validateStep);
var provisionStep = new PipelineStep
{
Name = ProvisionInfrastructureStepName,
Description = "Aggregation step for all Azure infrastructure provisioning operations.",
Action = _ => Task.CompletedTask,
Tags = [WellKnownPipelineTags.ProvisionInfrastructure],
RequiredBySteps = [WellKnownPipelineSteps.Deploy],
DependsOnSteps = [WellKnownPipelineSteps.DeployPrereq]
};
steps.Add(provisionStep);
provisionStep.DependsOn(createContextStep);
var destroyStep = new PipelineStep
{
Name = $"destroy-azure-{Name}",
Description = $"Destroys the Azure resource group and all resources for {Name}.",
Action = ctx => DestroyAzureResourcesAsync(ctx),
RequiredBySteps = [WellKnownPipelineSteps.Destroy],
DependsOnSteps = [WellKnownPipelineSteps.DestroyPrereq]
};
steps.Add(destroyStep);
return steps;
}));
Annotations.Add(ManifestPublishingCallbackAnnotation.Ignore);
Location = location;
ResourceGroupName = resourceGroupName;
PrincipalId = principalId;
}
/// <summary>
/// Adds Azure deployment information to the pipeline summary.
/// </summary>
/// <param name="ctx">The pipeline step context.</param>
/// <param name="provisioningContext">The Azure provisioning context.</param>
private static void AddToPipelineSummary(PipelineStepContext ctx, ProvisioningContext provisioningContext)
{
var resourceGroupName = provisioningContext.ResourceGroup.Name;
var subscriptionId = provisioningContext.Subscription.Id.Name;
var location = provisioningContext.Location.Name;
var tenantId = provisioningContext.Tenant.TenantId;
ctx.Summary.Add("☁️ Target", "Azure");
ctx.Summary.Add("📦 Resource Group", AzurePortalUrls.GetResourceGroupLink(subscriptionId, resourceGroupName, tenantId));
ctx.Summary.Add("📜 Deployments", AzurePortalUrls.GetResourceGroupDeploymentsLink(subscriptionId, resourceGroupName, tenantId));
ctx.Summary.Add("🔑 Subscription", subscriptionId);
ctx.Summary.Add("🌐 Location", location);
}
private Task PublishAsync(PipelineStepContext context)
{
var azureProvisioningOptions = context.Services.GetRequiredService<IOptions<AzureProvisioningOptions>>();
var outputService = context.Services.GetRequiredService<IPipelineOutputService>();
var publishingContext = new AzurePublishingContext(
outputService.GetOutputDirectory(),
azureProvisioningOptions.Value,
context.Services,
context.Logger,
context.ReportingStep);
return publishingContext.WriteModelAsync(context.Model, this);
}
private static async Task ValidateAzureLoginAsync(PipelineStepContext context)
{
var tokenCredentialProvider = context.Services.GetRequiredService<ITokenCredentialProvider>();
try
{
var tokenRequest = new TokenRequestContext(["https://management.azure.com/.default"]);
await tokenCredentialProvider.TokenCredential.GetTokenAsync(tokenRequest, context.CancellationToken)
.ConfigureAwait(false);
await context.ReportingStep.CompleteAsync(
"Azure CLI authentication validated successfully",
CompletionState.Completed,
context.CancellationToken).ConfigureAwait(false);
}
catch (Exception)
{
await context.ReportingStep.CompleteAsync(
new MarkdownString("Azure CLI authentication failed. Please run `az login` to authenticate before deploying. Learn more at [Azure CLI documentation](https://learn.microsoft.com/cli/azure/authenticate-azure-cli)."),
CompletionState.CompletedWithError,
context.CancellationToken).ConfigureAwait(false);
throw;
}
}
private static async Task DestroyAzureResourcesAsync(PipelineStepContext context)
{
var deploymentStateManager = context.Services.GetRequiredService<IDeploymentStateManager>();
var tokenCredentialProvider = context.Services.GetRequiredService<ITokenCredentialProvider>();
var armClientProvider = context.Services.GetRequiredService<IArmClientProvider>();
// Read deployment state to find the resource group
var azureStateSection = await deploymentStateManager.AcquireSectionAsync("Azure", context.CancellationToken).ConfigureAwait(false);
var resourceGroupName = azureStateSection.Data["ResourceGroup"]?.ToString();
var subscriptionId = azureStateSection.Data["SubscriptionId"]?.ToString();
if (string.IsNullOrEmpty(resourceGroupName) || string.IsNullOrEmpty(subscriptionId))
{
await context.ReportingStep.CompleteAsync(
"No Azure deployment state found. Nothing to destroy.",
CompletionState.Completed,
context.CancellationToken).ConfigureAwait(false);
return;
}
// Fail fast in non-interactive mode without --yes before doing any Azure work
var options = context.Services.GetRequiredService<IOptions<PipelineOptions>>();
if (!options.Value.SkipConfirmation)
{
var interactionService = context.Services.GetRequiredService<IInteractionService>();
if (!interactionService.IsAvailable)
{
throw new InvalidOperationException(
"Cannot perform destructive operation without confirmation. Use --yes to skip the confirmation prompt in non-interactive mode.");
}
}
var credential = tokenCredentialProvider.TokenCredential;
var armClient = armClientProvider.GetArmClient(credential, subscriptionId);
var (subscription, _) = await armClient.GetSubscriptionAndTenantAsync(context.CancellationToken).ConfigureAwait(false);
var resourceGroups = subscription.GetResourceGroups();
IResourceGroupResource resourceGroup;
try
{
var rgResponse = await resourceGroups.GetAsync(resourceGroupName, context.CancellationToken).ConfigureAwait(false);
resourceGroup = rgResponse.Value;
}
catch (RequestFailedException ex) when (ex.Status == 404)
{
// Resource group already deleted
await context.ReportingStep.CompleteAsync(
new MarkdownString($"Resource group **{resourceGroupName}** not found (already deleted)"),
CompletionState.Completed,
context.CancellationToken).ConfigureAwait(false);
return;
}
// Enumerate resources in the resource group so the user can see what will be destroyed
var resources = new List<(string Name, string ResourceType)>();
{
var discoveryTask = await context.ReportingStep.CreateTaskAsync(
new MarkdownString($"Discovering resources in **{resourceGroupName}**"),
context.CancellationToken).ConfigureAwait(false);
await using var _ = discoveryTask.ConfigureAwait(false);
try
{
await foreach (var resource in resourceGroup.GetResourcesAsync(context.CancellationToken).ConfigureAwait(false))
{
resources.Add(resource);
}
if (resources.Count == 0)
{
await discoveryTask.CompleteAsync(
new MarkdownString($"Resource group **{resourceGroupName}** is empty"),
CompletionState.Completed,
context.CancellationToken).ConfigureAwait(false);
}
else
{
foreach (var (name, type) in resources)
{
var shortType = type.StartsWith("Microsoft.", StringComparison.OrdinalIgnoreCase)
? type["Microsoft.".Length..]
: type;
context.Logger.LogInformation(" {Type}: {Name}", shortType, name);
}
await discoveryTask.CompleteAsync(
new MarkdownString($"Found **{resources.Count}** resource(s) in **{resourceGroupName}**"),
CompletionState.Completed,
context.CancellationToken).ConfigureAwait(false);
}
}
catch (Exception ex)
{
// Non-fatal — proceed with deletion even if enumeration fails
context.Logger.LogWarning(ex, "Failed to enumerate resources in resource group '{ResourceGroupName}'", resourceGroupName);
await discoveryTask.CompleteAsync(
"Could not enumerate resources (will proceed with deletion)",
CompletionState.Completed,
context.CancellationToken).ConfigureAwait(false);
}
}
// Confirm destruction with the user (unless --yes was specified)
if (!options.Value.SkipConfirmation)
{
var interactionService = context.Services.GetRequiredService<IInteractionService>();
var confirmMessage = resources.Count > 0
? $"Delete resource group '{resourceGroupName}' with {resources.Count} resource(s)? This action cannot be undone."
: $"Delete resource group '{resourceGroupName}'? This action cannot be undone.";
var result = await interactionService.PromptNotificationAsync(
"Destroy Azure resources",
confirmMessage,
new NotificationInteractionOptions
{
Intent = MessageIntent.Confirmation,
ShowSecondaryButton = true,
ShowDismiss = false,
PrimaryButtonText = "Destroy",
SecondaryButtonText = "Cancel"
},
context.CancellationToken).ConfigureAwait(false);
if (result.Canceled || !result.Data)
{
context.Logger.LogInformation("User canceled the destroy operation.");
throw new OperationCanceledException("Destroy operation canceled by user.");
}
}
// Delete the resource group
var deleteTask = await context.ReportingStep.CreateTaskAsync(
new MarkdownString($"Deleting resource group **{resourceGroupName}** ({resources.Count} resource(s))"),
context.CancellationToken).ConfigureAwait(false);
await using var __ = deleteTask.ConfigureAwait(false);
try
{
await resourceGroup.DeleteAsync(WaitUntil.Started, context.CancellationToken).ConfigureAwait(false);
var portalUrl = AzurePortalUrls.GetResourceGroupUrl(subscriptionId, resourceGroupName, subscription.TenantId);
context.Summary.Add("🗑️ Resource Group", new MarkdownString($"[{resourceGroupName}]({portalUrl})"));
context.Summary.Add("🔑 Subscription", subscriptionId);
context.Summary.Add("⏳ Status", new MarkdownString($"Deletion in progress. Monitor [here]({portalUrl})"));
await deleteTask.CompleteAsync(
new MarkdownString($"Resource group **{resourceGroupName}** deletion in progress. Monitor in the [Azure portal]({portalUrl})."),
CompletionState.Completed,
context.CancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
await deleteTask.CompleteAsync(
$"Failed to delete resource group '{resourceGroupName}': {ex.Message}",
CompletionState.CompletedWithError,
context.CancellationToken).ConfigureAwait(false);
throw;
}
}
}