Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 68 additions & 20 deletions src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ namespace Aspire.Hosting.Azure;
[Experimental("ASPIREAZURE001", UrlFormat = "https://aka.ms/dotnet/aspire/diagnostics#{0}")]
public sealed class AzureEnvironmentResource : Resource
{
private const string DefaultImageStepTag = "default-image-tags";

/// <summary>
/// Gets or sets the Azure location that the resources will be deployed to.
/// </summary>
Expand All @@ -46,6 +48,8 @@ public sealed class AzureEnvironmentResource : Resource
/// </summary>
public ParameterResource PrincipalId { get; set; }

private readonly List<IResource> _computeResourcesToBuild = [];

/// <summary>
/// Initializes a new instance of the <see cref="AzureEnvironmentResource"/> class.
/// </summary>
Expand Down Expand Up @@ -88,12 +92,20 @@ public AzureEnvironmentResource(string name, ParameterResource location, Paramet
};
provisionStep.DependsOn(createContextStep);

var addImageTagsStep = new PipelineStep
{
Name = DefaultImageStepTag,
Action = ctx => DefaultImageTags(ctx),
Tags = [DefaultImageStepTag],
};

var buildStep = new PipelineStep
{
Name = "build-container-images",
Action = ctx => BuildContainerImagesAsync(ctx),
Tags = [WellKnownPipelineTags.BuildCompute]
};
buildStep.DependsOn(addImageTagsStep);

var pushStep = new PipelineStep
{
Expand All @@ -119,7 +131,38 @@ public AzureEnvironmentResource(string name, ParameterResource location, Paramet
};
printDashboardUrlStep.DependsOn(deployStep);

return [validateStep, createContextStep, provisionStep, buildStep, pushStep, deployStep, printDashboardUrlStep];
return [validateStep, createContextStep, provisionStep, addImageTagsStep, buildStep, pushStep, deployStep, printDashboardUrlStep];
}));

Annotations.Add(new PipelineConfigurationAnnotation(context =>
{
var defaultImageTags = context.GetSteps(this, DefaultImageStepTag).Single();
var myBuildStep = context.GetSteps(this, WellKnownPipelineTags.BuildCompute).Single();

var computeResources = context.Model.GetComputeResources()
.Where(r => r.RequiresImageBuildAndPush())
.ToList();

foreach (var computeResource in computeResources)
{
var computeResourceBuildSteps = context.GetSteps(computeResource, WellKnownPipelineTags.BuildCompute);
if (computeResourceBuildSteps.Any())
{
// add the appropriate dependencies to the compute resource's build steps
foreach (var computeBuildStep in computeResourceBuildSteps)
{
computeBuildStep.DependsOn(defaultImageTags);
myBuildStep.DependsOn(computeBuildStep);
}
}
else
{
// No build step exists for this compute resource, so we add it to the main build step
_computeResourcesToBuild.Add(computeResource);
}
}

return Task.CompletedTask;
}));

Annotations.Add(ManifestPublishingCallbackAnnotation.Ignore);
Expand All @@ -129,6 +172,26 @@ public AzureEnvironmentResource(string name, ParameterResource location, Paramet
PrincipalId = principalId;
}

private static Task DefaultImageTags(PipelineStepContext context)
{
var computeResources = context.Model.GetComputeResources()
.Where(r => r.RequiresImageBuildAndPush())
.ToList();

var deploymentTag = $"aspire-deploy-{DateTime.UtcNow:yyyyMMddHHmmss}";
foreach (var resource in computeResources)
{
if (resource.TryGetLastAnnotation<DeploymentImageTagCallbackAnnotation>(out _))
{
continue;
}
resource.Annotations.Add(
new DeploymentImageTagCallbackAnnotation(_ => deploymentTag));
}

return Task.CompletedTask;
}

private Task PublishAsync(PublishingContext context)
{
var azureProvisioningOptions = context.Services.GetRequiredService<IOptions<AzureProvisioningOptions>>();
Expand Down Expand Up @@ -243,32 +306,17 @@ await resourceTask.CompleteAsync(
await Task.WhenAll(provisioningTasks).ConfigureAwait(false);
}

private static async Task BuildContainerImagesAsync(PipelineStepContext context)
private async Task BuildContainerImagesAsync(PipelineStepContext context)
{
var containerImageBuilder = context.Services.GetRequiredService<IResourceContainerImageBuilder>();

var computeResources = context.Model.GetComputeResources()
.Where(r => r.RequiresImageBuildAndPush())
.ToList();

if (!computeResources.Any())
if (!_computeResourcesToBuild.Any())
{
return;
}

var deploymentTag = $"aspire-deploy-{DateTime.UtcNow:yyyyMMddHHmmss}";
foreach (var resource in computeResources)
{
if (resource.TryGetLastAnnotation<DeploymentImageTagCallbackAnnotation>(out _))
{
continue;
}
resource.Annotations.Add(
new DeploymentImageTagCallbackAnnotation(_ => deploymentTag));
}
var containerImageBuilder = context.Services.GetRequiredService<IResourceContainerImageBuilder>();

await containerImageBuilder.BuildImagesAsync(
computeResources,
_computeResourcesToBuild,
new ContainerBuildOptions
{
TargetPlatform = ContainerTargetPlatform.LinuxAmd64
Expand Down
70 changes: 70 additions & 0 deletions tests/Aspire.Hosting.Azure.Tests/AzureDeployerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
#pragma warning disable ASPIRECOMPUTE001 // 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.
#pragma warning disable ASPIREINTERACTION001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
#pragma warning disable ASPIREPIPELINES001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.

using Aspire.Hosting.Utils;
using Aspire.Hosting.Tests;
Expand Down Expand Up @@ -131,6 +132,75 @@ public async Task DeployAsync_PromptsViaInteractionService()
await runTask.WaitAsync(TimeSpan.FromSeconds(10));
}

/// <summary>
/// Verifies that deploying an application with resources that define their own build steps does not trigger default
/// image build and they have the correct pipeline configuration.
/// </summary>
[Fact]
public async Task DeployAsync_WithResourcesWithBuildSteps()
{
// Arrange
var mockProcessRunner = new MockProcessRunner();
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, publisher: "default", isDeploy: true);
var armClientProvider = new TestArmClientProvider(new Dictionary<string, object>
{
["AZURE_CONTAINER_REGISTRY_NAME"] = new { type = "String", value = "testregistry" },
["AZURE_CONTAINER_REGISTRY_ENDPOINT"] = new { type = "String", value = "testregistry.azurecr.io" },
["AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID"] = new { type = "String", value = "/subscriptions/test/resourceGroups/test-rg/providers/Microsoft.ManagedIdentity/userAssignedIdentities/test-identity" },
["AZURE_CONTAINER_APPS_ENVIRONMENT_DEFAULT_DOMAIN"] = new { type = "String", value = "test.westus.azurecontainerapps.io" },
["AZURE_CONTAINER_APPS_ENVIRONMENT_ID"] = new { type = "String", value = "/subscriptions/test/resourceGroups/test-rg/providers/Microsoft.App/managedEnvironments/testenv" }
});
ConfigureTestServices(builder, armClientProvider: armClientProvider);

var containerAppEnv = builder.AddAzureContainerAppEnvironment("env");

var configCalled = false;

// Add a compute resource with its own build step
builder.AddProject<Project>("api", launchProfileName: null)
.WithPipelineStepFactory(factoryContext =>
{
return
[
new PipelineStep
{
Name = "api-build",
Action = _ => Task.CompletedTask,
Tags = [WellKnownPipelineTags.BuildCompute]
}
];
})
.WithPipelineConfiguration(configContext =>
{
var mainBuildStep = configContext.GetSteps(WellKnownPipelineTags.BuildCompute)
.Where(s => s.Name == "build-container-images")
.Single();

Assert.Contains("api-build", mainBuildStep.DependsOnSteps);

var apiBuildstep = configContext.GetSteps(WellKnownPipelineTags.BuildCompute)
.Where(s => s.Name == "api-build")
.Single();

Assert.Contains("default-image-tags", apiBuildstep.DependsOnSteps);

configCalled = true;
});

using var app = builder.Build();
await app.StartAsync();
await app.WaitForShutdownAsync();

Assert.True(configCalled);

// Assert - Verify MockImageBuilder was NOT called because the project resource has its own build step
var mockImageBuilder = app.Services.GetRequiredService<IResourceContainerImageBuilder>() as MockImageBuilder;
Assert.NotNull(mockImageBuilder);
Assert.False(mockImageBuilder.BuildImageCalled);
Assert.False(mockImageBuilder.BuildImagesCalled);
Assert.Empty(mockImageBuilder.BuildImageResources);
}

[Fact]
public async Task DeployAsync_WithAzureStorageResourcesWorks()
{
Expand Down