|
// 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 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 ASPIREPIPELINES002 // 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.
#pragma warning disable ASPIREDOCKERFILEBUILDER001 // 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.
using System.Text.Json.Nodes;
using Aspire.Hosting.ApplicationModel;
using Aspire.Hosting.Azure.Provisioning;
using Aspire.Hosting.Azure.Provisioning.Internal;
using Aspire.Hosting.Pipelines;
using Aspire.Hosting.Publishing;
using Aspire.Hosting.Publishing.Internal;
using Aspire.Hosting.Testing;
using Aspire.Hosting.Tests;
using Aspire.Hosting.Utils;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace Aspire.Hosting.Azure.Tests;
public class AzureDeployerTests
{
[Fact]
public async Task DeployAsync_PromptsViaInteractionService()
{
// Arrange
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, step: WellKnownPipelineSteps.Deploy);
var testInteractionService = new TestInteractionService();
ConfigureTestServices(builder, interactionService: testInteractionService, bicepProvisioner: new NoOpBicepProvisioner(), setDefaultProvisioningOptions: false);
// Add an Azure environment resource which will trigger the deployment prompting
builder.AddAzureEnvironment();
// Act
using var app = builder.Build();
var runTask = Task.Run(app.Run);
// Wait for the first interaction (subscription selection)
var tenantInteraction = await testInteractionService.Interactions.Reader.ReadAsync();
Assert.Equal("Azure tenant", tenantInteraction.Title);
Assert.False(tenantInteraction.Options!.EnableMessageMarkdown);
Assert.Collection(tenantInteraction.Inputs,
input =>
{
Assert.Equal("Tenant ID", input.Label);
Assert.Equal(InputType.Choice, input.InputType);
Assert.True(input.Required);
});
tenantInteraction.Inputs[0].Value = "87654321-4321-4321-4321-210987654321";
tenantInteraction.CompletionTcs.SetResult(InteractionResult.Ok(tenantInteraction.Inputs));
// Wait for the next interaction (subscription selection)
var subscriptionInteraction = await testInteractionService.Interactions.Reader.ReadAsync();
Assert.Equal("Azure subscription", subscriptionInteraction.Title);
Assert.False(subscriptionInteraction.Options!.EnableMessageMarkdown);
// Verify the expected input for subscription selection (fallback to manual entry)
Assert.Collection(subscriptionInteraction.Inputs,
input =>
{
Assert.Equal("Subscription ID", input.Label);
Assert.Equal(InputType.Choice, input.InputType);
Assert.True(input.Required);
});
// Complete the subscription interaction
subscriptionInteraction.Inputs[0].Value = "12345678-1234-1234-1234-123456789012";
subscriptionInteraction.CompletionTcs.SetResult(InteractionResult.Ok(subscriptionInteraction.Inputs));
// Wait for the second interaction (location and resource group selection)
var locationInteraction = await testInteractionService.Interactions.Reader.ReadAsync();
Assert.Equal("Azure location and resource group", locationInteraction.Title);
Assert.False(locationInteraction.Options!.EnableMessageMarkdown);
// Verify the expected inputs for location and resource group (fallback to manual entry)
Assert.Collection(locationInteraction.Inputs,
input =>
{
Assert.Equal("Location", input.Label);
Assert.Equal(InputType.Choice, input.InputType);
Assert.True(input.Required);
},
input =>
{
Assert.Equal("Resource group", input.Label);
Assert.Equal(InputType.Text, input.InputType);
Assert.False(input.Required);
});
// Complete the location interaction
locationInteraction.Inputs[0].Value = "westus2";
locationInteraction.Inputs[1].Value = "test-rg";
locationInteraction.CompletionTcs.SetResult(InteractionResult.Ok(locationInteraction.Inputs));
// Wait for the run task to complete (or timeout)
await runTask.WaitAsync(TimeSpan.FromSeconds(10));
}
/// <summary>
/// Verifies that deploying an application with resources that are build-only containers only builds
/// the containers and does not attempt to push them.
/// </summary>
[Fact]
public async Task DeployAsync_WithBuildOnlyContainers()
{
// Arrange
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, step: WellKnownPipelineSteps.Deploy);
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");
// Add a build-only container resource
builder.AddExecutable("exe", "exe", ".")
.PublishAsDockerFile(c =>
{
c.WithDockerfileBuilder(".", dockerfileContext =>
{
var dockerBuilder = dockerfileContext.Builder
.From("scratch");
});
var dockerFileAnnotation = c.Resource.Annotations.OfType<DockerfileBuildAnnotation>().Single();
dockerFileAnnotation.HasEntrypoint = false;
});
using var app = builder.Build();
await app.StartAsync();
await app.WaitForShutdownAsync();
// Assert - Verify MockImageBuilder was only called to build an image and not push it
var mockImageBuilder = app.Services.GetRequiredService<IResourceContainerImageBuilder>() as MockImageBuilder;
Assert.NotNull(mockImageBuilder);
Assert.True(mockImageBuilder.BuildImageCalled);
var builtImage = Assert.Single(mockImageBuilder.BuildImageResources);
Assert.Equal("exe", builtImage.Name);
Assert.False(mockImageBuilder.PushImageCalled);
}
[Fact]
public async Task DeployAsync_WithAzureStorageResourcesWorks()
{
// Arrange
var mockProcessRunner = new MockProcessRunner();
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, step: WellKnownPipelineSteps.Deploy);
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 azureEnv = builder.AddAzureEnvironment();
// Add Azure Storage with blob containers and queues
var storage = builder.AddAzureStorage("teststorage");
storage.AddBlobContainer("container1", blobContainerName: "test-container-1");
storage.AddBlobContainer("container2", blobContainerName: "test-container-2");
storage.AddQueue("testqueue", queueName: "test-queue");
using var app = builder.Build();
await app.StartAsync();
await app.WaitForShutdownAsync();
// Assert that ACR login command was not executed given no compute resources
Assert.DoesNotContain(mockProcessRunner.ExecutedCommands,
cmd => cmd.ExecutablePath.Contains("az") &&
cmd.Arguments == "acr login --name testregistry");
// Assert - Verify MockImageBuilder was NOT called when there are no compute resources
var mockImageBuilder = app.Services.GetRequiredService<IResourceContainerImageBuilder>() as MockImageBuilder;
Assert.NotNull(mockImageBuilder);
Assert.False(mockImageBuilder.BuildImageCalled);
Assert.False(mockImageBuilder.BuildImagesCalled);
Assert.False(mockImageBuilder.TagImageCalled);
Assert.False(mockImageBuilder.PushImageCalled);
Assert.Empty(mockImageBuilder.BuildImageResources);
Assert.Empty(mockImageBuilder.TagImageCalls);
Assert.Empty(mockImageBuilder.PushImageCalls);
}
[Fact]
public async Task DeployAsync_WithContainer_Works()
{
// Arrange
var mockProcessRunner = new MockProcessRunner();
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, step: WellKnownPipelineSteps.Deploy);
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, processRunner: mockProcessRunner);
var containerAppEnv = builder.AddAzureContainerAppEnvironment("env");
var azureEnv = builder.AddAzureEnvironment();
builder.AddContainer("api", "my-api-image:latest");
// Act
using var app = builder.Build();
await app.StartAsync();
await app.WaitForShutdownAsync();
// Assert that container environment outputs are propagated to outputs because they are
// hoisted up for the container resource
Assert.Equal("testregistry", containerAppEnv.Resource.Outputs["AZURE_CONTAINER_REGISTRY_NAME"]);
Assert.Equal("testregistry.azurecr.io", containerAppEnv.Resource.Outputs["AZURE_CONTAINER_REGISTRY_ENDPOINT"]);
Assert.Equal("/subscriptions/test/resourceGroups/test-rg/providers/Microsoft.ManagedIdentity/userAssignedIdentities/test-identity", containerAppEnv.Resource.Outputs["AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID"]);
Assert.Equal("test.westus.azurecontainerapps.io", containerAppEnv.Resource.Outputs["AZURE_CONTAINER_APPS_ENVIRONMENT_DEFAULT_DOMAIN"]);
Assert.Equal("/subscriptions/test/resourceGroups/test-rg/providers/Microsoft.App/managedEnvironments/testenv", containerAppEnv.Resource.Outputs["AZURE_CONTAINER_APPS_ENVIRONMENT_ID"]);
// Assert - Verify ACR login command was not called since no image was pushed
Assert.DoesNotContain(mockProcessRunner.ExecutedCommands,
cmd => cmd.ExecutablePath.Contains("az") &&
cmd.Arguments == "acr login --name testregistry");
// Assert - Verify MockImageBuilder tag and push methods were NOT called for existing container image
var mockImageBuilder = app.Services.GetRequiredService<IResourceContainerImageBuilder>() as MockImageBuilder;
Assert.NotNull(mockImageBuilder);
Assert.False(mockImageBuilder.TagImageCalled);
Assert.False(mockImageBuilder.PushImageCalled);
Assert.Empty(mockImageBuilder.TagImageCalls);
Assert.Empty(mockImageBuilder.PushImageCalls);
}
[Fact]
public async Task DeployAsync_WithDockerfile_Works()
{
// Arrange
var mockProcessRunner = new MockProcessRunner();
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, step: WellKnownPipelineSteps.Deploy);
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, processRunner: mockProcessRunner);
var containerAppEnv = builder.AddAzureContainerAppEnvironment("env");
var azureEnv = builder.AddAzureEnvironment();
builder.AddDockerfile("api", "api.Dockerfile");
// Act
using var app = builder.Build();
await app.StartAsync();
await app.WaitForShutdownAsync();
// Assert that container environment outputs are propagated to outputs because they are
// hoisted up for the container resource
Assert.Equal("testregistry", containerAppEnv.Resource.Outputs["AZURE_CONTAINER_REGISTRY_NAME"]);
Assert.Equal("testregistry.azurecr.io", containerAppEnv.Resource.Outputs["AZURE_CONTAINER_REGISTRY_ENDPOINT"]);
Assert.Equal("/subscriptions/test/resourceGroups/test-rg/providers/Microsoft.ManagedIdentity/userAssignedIdentities/test-identity", containerAppEnv.Resource.Outputs["AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID"]);
Assert.Equal("test.westus.azurecontainerapps.io", containerAppEnv.Resource.Outputs["AZURE_CONTAINER_APPS_ENVIRONMENT_DEFAULT_DOMAIN"]);
Assert.Equal("/subscriptions/test/resourceGroups/test-rg/providers/Microsoft.App/managedEnvironments/testenv", containerAppEnv.Resource.Outputs["AZURE_CONTAINER_APPS_ENVIRONMENT_ID"]);
// Assert - Verify ACR login command was called since Dockerfile image needs to be pushed
Assert.Contains(mockProcessRunner.ExecutedCommands,
cmd => cmd.ExecutablePath.Contains("az") &&
cmd.Arguments == "acr login --name testregistry");
// Assert - Verify MockImageBuilder tag and push methods were called
var mockImageBuilder = app.Services.GetRequiredService<IResourceContainerImageBuilder>() as MockImageBuilder;
Assert.NotNull(mockImageBuilder);
Assert.True(mockImageBuilder.TagImageCalled);
Assert.True(mockImageBuilder.PushImageCalled);
// Verify specific tag call was made (local "api" to target in testregistry with deployment tag)
Assert.Contains(mockImageBuilder.TagImageCalls, call =>
call.localImageName.StartsWith("api:") &&
call.targetImageName.StartsWith("testregistry.azurecr.io/") &&
call.targetImageName.Contains("aspire-deploy-"));
// Verify specific push call was made with deployment tag
Assert.Contains(mockImageBuilder.PushImageCalls, imageName =>
imageName.StartsWith("testregistry.azurecr.io/") &&
imageName.Contains("aspire-deploy-"));
}
[Fact]
public async Task DeployAsync_WithProjectResource_Works()
{
// Arrange
var mockProcessRunner = new MockProcessRunner();
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, step: WellKnownPipelineSteps.Deploy);
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, processRunner: mockProcessRunner);
var containerAppEnv = builder.AddAzureContainerAppEnvironment("env");
var azureEnv = builder.AddAzureEnvironment();
builder.AddProject<Project>("api", launchProfileName: null);
// Act
using var app = builder.Build();
await app.StartAsync();
await app.WaitForShutdownAsync();
// Assert that container environment outputs are propagated to outputs because they are
// hoisted up for the container resource
Assert.Equal("testregistry", containerAppEnv.Resource.Outputs["AZURE_CONTAINER_REGISTRY_NAME"]);
Assert.Equal("testregistry.azurecr.io", containerAppEnv.Resource.Outputs["AZURE_CONTAINER_REGISTRY_ENDPOINT"]);
Assert.Equal("/subscriptions/test/resourceGroups/test-rg/providers/Microsoft.ManagedIdentity/userAssignedIdentities/test-identity", containerAppEnv.Resource.Outputs["AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID"]);
Assert.Equal("test.westus.azurecontainerapps.io", containerAppEnv.Resource.Outputs["AZURE_CONTAINER_APPS_ENVIRONMENT_DEFAULT_DOMAIN"]);
Assert.Equal("/subscriptions/test/resourceGroups/test-rg/providers/Microsoft.App/managedEnvironments/testenv", containerAppEnv.Resource.Outputs["AZURE_CONTAINER_APPS_ENVIRONMENT_ID"]);
// Assert - Verify ACR login command was called
Assert.Contains(mockProcessRunner.ExecutedCommands,
cmd => cmd.ExecutablePath.Contains("az") &&
cmd.Arguments == "acr login --name testregistry");
// Assert - Verify MockImageBuilder tag and push methods were called
var mockImageBuilder = app.Services.GetRequiredService<IResourceContainerImageBuilder>() as MockImageBuilder;
Assert.NotNull(mockImageBuilder);
Assert.True(mockImageBuilder.TagImageCalled);
Assert.True(mockImageBuilder.PushImageCalled);
// Verify specific tag call was made (local "api" to target in testregistry with deployment tag)
Assert.Contains(mockImageBuilder.TagImageCalls, call =>
call.localImageName == "api" &&
call.targetImageName.StartsWith("testregistry.azurecr.io/") &&
call.targetImageName.Contains("aspire-deploy-"));
// Verify specific push call was made with deployment tag
Assert.Contains(mockImageBuilder.PushImageCalls, imageName =>
imageName.StartsWith("testregistry.azurecr.io/") &&
imageName.Contains("aspire-deploy-"));
}
[Theory]
[InlineData("deploy")]
[InlineData("diagnostics")]
public async Task DeployAsync_WithMultipleComputeEnvironments_Works(string step)
{
// Arrange
var mockProcessRunner = new MockProcessRunner();
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, step: step);
var mockActivityReporter = new TestPublishingActivityReporter();
var armClientProvider = new TestArmClientProvider(deploymentName =>
{
return deploymentName switch
{
string name when name.StartsWith("aca-env") => new Dictionary<string, object>
{
["AZURE_CONTAINER_REGISTRY_NAME"] = new { type = "String", value = "acaregistry" },
["AZURE_CONTAINER_REGISTRY_ENDPOINT"] = new { type = "String", value = "acaregistry.azurecr.io" },
["AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID"] = new { type = "String", value = "/subscriptions/test/resourceGroups/test-rg/providers/Microsoft.ManagedIdentity/userAssignedIdentities/aca-identity" },
["AZURE_CONTAINER_APPS_ENVIRONMENT_DEFAULT_DOMAIN"] = new { type = "String", value = "aca.westus.azurecontainerapps.io" },
["AZURE_CONTAINER_APPS_ENVIRONMENT_ID"] = new { type = "String", value = "/subscriptions/test/resourceGroups/test-rg/providers/Microsoft.App/managedEnvironments/acaenv" }
},
string name when name.StartsWith("aas-env") => new Dictionary<string, object>
{
["AZURE_CONTAINER_REGISTRY_NAME"] = new { type = "String", value = "aasregistry" },
["AZURE_CONTAINER_REGISTRY_ENDPOINT"] = new { type = "String", value = "aasregistry.azurecr.io" },
["AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID"] = new { type = "String", value = "/subscriptions/test/resourceGroups/test-rg/providers/Microsoft.ManagedIdentity/userAssignedIdentities/aas-identity" },
["planId"] = new { type = "String", value = "/subscriptions/test/resourceGroups/test-rg/providers/Microsoft.Web/serverfarms/aasplan" },
["AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_CLIENT_ID"] = new { type = "String", value = "aas-client-id" }
},
_ => []
};
});
ConfigureTestServices(builder, armClientProvider: armClientProvider, processRunner: mockProcessRunner, activityReporter: mockActivityReporter);
var acaEnv = builder.AddAzureContainerAppEnvironment("aca-env");
var aasEnv = builder.AddAzureAppServiceEnvironment("aas-env");
var azureEnv = builder.AddAzureEnvironment();
var storage = builder.AddAzureStorage("storage");
storage.AddBlobContainer("mycontainer1", blobContainerName: "test-container-1");
storage.AddBlobContainer("mycontainer2", blobContainerName: "test-container-2");
storage.AddQueue("myqueue", queueName: "my-queue");
builder.AddRedis("cache").WithComputeEnvironment(acaEnv);
builder.AddProject<Project>("api-service", launchProfileName: null).WithComputeEnvironment(aasEnv);
builder.AddDockerfile("python-app", "python-app.Dockerfile").WithComputeEnvironment(acaEnv);
// Act
using var app = builder.Build();
await app.StartAsync();
await app.WaitForShutdownAsync();
if (step == "diagnostics")
{
// In diagnostics mode, just verify logs match snapshot
var logs = mockActivityReporter.LoggedMessages
.Where(s => s.StepTitle == "diagnostics")
.Select(s => s.Message)
.ToList();
await Verify(logs);
return;
}
// Assert ACA environment outputs are properly set
Assert.Equal("acaregistry", acaEnv.Resource.Outputs["AZURE_CONTAINER_REGISTRY_NAME"]);
Assert.Equal("acaregistry.azurecr.io", acaEnv.Resource.Outputs["AZURE_CONTAINER_REGISTRY_ENDPOINT"]);
Assert.Equal("/subscriptions/test/resourceGroups/test-rg/providers/Microsoft.ManagedIdentity/userAssignedIdentities/aca-identity", acaEnv.Resource.Outputs["AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID"]);
Assert.Equal("aca.westus.azurecontainerapps.io", acaEnv.Resource.Outputs["AZURE_CONTAINER_APPS_ENVIRONMENT_DEFAULT_DOMAIN"]);
Assert.Equal("/subscriptions/test/resourceGroups/test-rg/providers/Microsoft.App/managedEnvironments/acaenv", acaEnv.Resource.Outputs["AZURE_CONTAINER_APPS_ENVIRONMENT_ID"]);
// Assert AAS environment outputs are properly set
Assert.Equal("aasregistry", aasEnv.Resource.Outputs["AZURE_CONTAINER_REGISTRY_NAME"]);
Assert.Equal("aasregistry.azurecr.io", aasEnv.Resource.Outputs["AZURE_CONTAINER_REGISTRY_ENDPOINT"]);
Assert.Equal("/subscriptions/test/resourceGroups/test-rg/providers/Microsoft.ManagedIdentity/userAssignedIdentities/aas-identity", aasEnv.Resource.Outputs["AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID"]);
Assert.Equal("/subscriptions/test/resourceGroups/test-rg/providers/Microsoft.Web/serverfarms/aasplan", aasEnv.Resource.Outputs["planId"]);
Assert.Equal("aas-client-id", aasEnv.Resource.Outputs["AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_CLIENT_ID"]);
// Assert ACR login commands were called for both registries
Assert.Contains(mockProcessRunner.ExecutedCommands,
cmd => cmd.ExecutablePath.Contains("az") &&
cmd.Arguments == "acr login --name acaregistry");
Assert.Contains(mockProcessRunner.ExecutedCommands,
cmd => cmd.ExecutablePath.Contains("az") &&
cmd.Arguments == "acr login --name aasregistry");
// Assert - Verify MockImageBuilder tag and push methods were called for multiple registries
var mockImageBuilder = app.Services.GetRequiredService<IResourceContainerImageBuilder>() as MockImageBuilder;
Assert.NotNull(mockImageBuilder);
Assert.True(mockImageBuilder.TagImageCalled);
Assert.True(mockImageBuilder.PushImageCalled);
// Verify tag calls were made for both registries with deployment tags
Assert.Contains(mockImageBuilder.TagImageCalls, call =>
call.localImageName == "api-service" &&
call.targetImageName.StartsWith("aasregistry.azurecr.io/") &&
call.targetImageName.Contains("aspire-deploy-"));
Assert.Contains(mockImageBuilder.TagImageCalls, call =>
call.localImageName.StartsWith("python-app:") &&
call.targetImageName.StartsWith("acaregistry.azurecr.io/") &&
call.targetImageName.Contains("aspire-deploy-"));
// Verify push calls were made for both registries with deployment tags
Assert.Contains(mockImageBuilder.PushImageCalls, imageName =>
imageName.StartsWith("aasregistry.azurecr.io/") &&
imageName.Contains("aspire-deploy-"));
Assert.Contains(mockImageBuilder.PushImageCalls, imageName =>
imageName.StartsWith("acaregistry.azurecr.io/") &&
imageName.Contains("aspire-deploy-"));
// Verify that redis (existing container) was not tagged/pushed
Assert.DoesNotContain(mockImageBuilder.TagImageCalls, call => call.localImageName == "cache");
}
[Fact]
public async Task DeployAsync_WithUnresolvedParameters_PromptsForParameterValues()
{
// Arrange
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, step: WellKnownPipelineSteps.Deploy);
var testInteractionService = new TestInteractionService();
ConfigureTestServices(builder, interactionService: testInteractionService, bicepProvisioner: new NoOpBicepProvisioner());
// Add a parameter that will be unresolved
var param = builder.AddParameter("test-param");
builder.AddAzureEnvironment();
// Act
using var app = builder.Build();
var runTask = Task.Run(app.Run);
// Wait for the parameter inputs interaction (no notification in publish mode)
var parameterInputs = await testInteractionService.Interactions.Reader.ReadAsync();
Assert.Equal("Set unresolved parameters", parameterInputs.Title);
// Verify the parameter input (should not include save to secrets option in publish mode)
Assert.Collection(parameterInputs.Inputs,
input =>
{
Assert.Equal("test-param", input.Label);
Assert.Equal(InputType.Text, input.InputType);
Assert.Equal("Enter value for test-param", input.Placeholder);
});
// Complete the parameter inputs interaction
parameterInputs.Inputs[0].Value = "test-value";
parameterInputs.CompletionTcs.SetResult(InteractionResult.Ok(parameterInputs.Inputs));
// Wait for the run task to complete (or timeout)
await runTask.WaitAsync(TimeSpan.FromSeconds(10));
var setValue = await param.Resource.GetValueAsync(default);
Assert.Equal("test-value", setValue);
}
[Fact]
public async Task DeployAsync_WithResolvedParameters_SkipsPrompting()
{
// Arrange
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, step: WellKnownPipelineSteps.Deploy);
var testInteractionService = new TestInteractionService();
ConfigureTestServices(builder, interactionService: testInteractionService, bicepProvisioner: new NoOpBicepProvisioner());
builder.Configuration["Parameters:test-param-2"] = "resolved-value-2";
// Add a parameter with a resolved value
var param = builder.AddParameter("test-param", () => "resolved-value");
var secondParam = builder.AddParameter("test-param-2");
builder.AddAzureEnvironment();
// Act
using var app = builder.Build();
await app.StartAsync();
await app.WaitForShutdownAsync();
Assert.Equal(0, testInteractionService.Interactions.Reader.Count);
}
[Fact]
public async Task DeployAsync_WithCustomInputGeneratorParameter_RespectsInputGenerator()
{
// Arrange
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, step: WellKnownPipelineSteps.Deploy);
var testInteractionService = new TestInteractionService();
ConfigureTestServices(builder, interactionService: testInteractionService, bicepProvisioner: new NoOpBicepProvisioner());
// Add a parameter with a custom input generator
var param = builder.AddParameter("custom-param")
.WithCustomInput(p => new InteractionInput
{
Name = p.Name,
InputType = InputType.Number,
Label = "Custom Port Number",
Description = "Enter a custom port number for the service",
EnableDescriptionMarkdown = false,
Placeholder = "8080"
});
builder.AddAzureEnvironment();
// Act
using var app = builder.Build();
var runTask = Task.Run(app.Run);
// Wait for the parameter inputs interaction (no notification in publish mode)
var parameterInputs = await testInteractionService.Interactions.Reader.ReadAsync();
Assert.Equal("Set unresolved parameters", parameterInputs.Title);
// Verify the custom input generator is respected (should not include save to secrets option in publish mode)
Assert.Collection(parameterInputs.Inputs,
input =>
{
Assert.Equal("custom-param", input.Name);
Assert.Equal("Custom Port Number", input.Label);
Assert.Equal("Enter a custom port number for the service", input.Description);
Assert.Equal(InputType.Number, input.InputType);
Assert.Equal("8080", input.Placeholder);
Assert.False(input.EnableDescriptionMarkdown);
});
// Complete the parameter inputs interaction
parameterInputs.Inputs[0].Value = "9090";
parameterInputs.CompletionTcs.SetResult(InteractionResult.Ok(parameterInputs.Inputs));
// Wait for the run task to complete (or timeout)
await runTask.WaitAsync(TimeSpan.FromSeconds(10));
var setValue = await param.Resource.GetValueAsync(default);
Assert.Equal("9090", setValue);
}
[Fact]
public async Task DeployAsync_WithSingleRedisCache_CallsDeployingComputeResources()
{
// Arrange
var mockProcessRunner = new MockProcessRunner();
var mockActivityReporter = new TestPublishingActivityReporter();
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, step: WellKnownPipelineSteps.Deploy);
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, processRunner: mockProcessRunner, activityReporter: mockActivityReporter);
var containerAppEnv = builder.AddAzureContainerAppEnvironment("env");
var azureEnv = builder.AddAzureEnvironment();
// Add a single Redis cache resource which is a compute resource
builder.AddRedis("cache").WithComputeEnvironment(containerAppEnv);
// Act
using var app = builder.Build();
await app.StartAsync();
await app.WaitForShutdownAsync();
// Assert that container environment outputs are propagated
Assert.Equal("testregistry", containerAppEnv.Resource.Outputs["AZURE_CONTAINER_REGISTRY_NAME"]);
Assert.Equal("testregistry.azurecr.io", containerAppEnv.Resource.Outputs["AZURE_CONTAINER_REGISTRY_ENDPOINT"]);
Assert.Equal("/subscriptions/test/resourceGroups/test-rg/providers/Microsoft.ManagedIdentity/userAssignedIdentities/test-identity", containerAppEnv.Resource.Outputs["AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID"]);
Assert.Equal("test.westus.azurecontainerapps.io", containerAppEnv.Resource.Outputs["AZURE_CONTAINER_APPS_ENVIRONMENT_DEFAULT_DOMAIN"]);
Assert.Equal("/subscriptions/test/resourceGroups/test-rg/providers/Microsoft.App/managedEnvironments/testenv", containerAppEnv.Resource.Outputs["AZURE_CONTAINER_APPS_ENVIRONMENT_ID"]);
// Assert that compute resources deployment logic was triggered (Redis doesn't require image build/push)
var mockImageBuilder = app.Services.GetRequiredService<IResourceContainerImageBuilder>() as MockImageBuilder;
Assert.NotNull(mockImageBuilder);
Assert.False(mockImageBuilder.BuildImageCalled);
Assert.False(mockImageBuilder.TagImageCalled);
Assert.False(mockImageBuilder.PushImageCalled);
// Assert that ACR login was not called since Redis uses existing container image
Assert.DoesNotContain(mockProcessRunner.ExecutedCommands,
cmd => cmd.ExecutablePath.Contains("az") &&
cmd.Arguments == "acr login --name testregistry");
// Assert that deploying steps executed
Assert.Contains("provision-cache-containerapp", mockActivityReporter.CreatedSteps);
}
[Fact]
public async Task DeployAsync_WithOnlyAzureResources_PrintsDashboardUrl()
{
// Arrange
var mockProcessRunner = new MockProcessRunner();
var mockActivityReporter = new TestPublishingActivityReporter();
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, step: WellKnownPipelineSteps.Deploy);
var armClientProvider = new TestArmClientProvider(new Dictionary<string, object>
{
["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, processRunner: mockProcessRunner, activityReporter: mockActivityReporter);
var containerAppEnv = builder.AddAzureContainerAppEnvironment("env");
var azureEnv = builder.AddAzureEnvironment();
// Add only Azure resources (no compute resources)
var storage = builder.AddAzureStorage("teststorage");
storage.AddBlobContainer("container1", blobContainerName: "test-container-1");
// Act
using var app = builder.Build();
await app.StartAsync();
await app.WaitForShutdownAsync();
// Assert that container environment outputs are propagated
Assert.Equal("test.westus.azurecontainerapps.io", containerAppEnv.Resource.Outputs["AZURE_CONTAINER_APPS_ENVIRONMENT_DEFAULT_DOMAIN"]);
Assert.Equal("/subscriptions/test/resourceGroups/test-rg/providers/Microsoft.App/managedEnvironments/testenv", containerAppEnv.Resource.Outputs["AZURE_CONTAINER_APPS_ENVIRONMENT_ID"]);
// Assert that no compute resources were deployed (no image build/push)
var mockImageBuilder = app.Services.GetRequiredService<IResourceContainerImageBuilder>() as MockImageBuilder;
Assert.NotNull(mockImageBuilder);
Assert.False(mockImageBuilder.BuildImageCalled);
Assert.False(mockImageBuilder.TagImageCalled);
Assert.False(mockImageBuilder.PushImageCalled);
// Assert that ACR login was not called since no compute resources
Assert.DoesNotContain(mockProcessRunner.ExecutedCommands,
cmd => cmd.ExecutablePath.Contains("az") &&
cmd.Arguments == "acr login --name testregistry");
// Assert that the completion request was called
Assert.True(mockActivityReporter.CompletePublishCalled);
}
[Fact]
public async Task DeployAsync_WithGeneratedParameters_DoesNotPromptsForParameterValues()
{
// Arrange
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, step: WellKnownPipelineSteps.Deploy);
var testInteractionService = new TestInteractionService();
ConfigureTestServices(builder, interactionService: testInteractionService, bicepProvisioner: new NoOpBicepProvisioner());
// Add a parameter with GenerateParameterDefault (like Redis password)
var redis = builder.AddRedis("cache");
builder.AddAzureEnvironment();
// Act
using var app = builder.Build();
await app.StartAsync();
await app.WaitForShutdownAsync();
Assert.Equal(0, testInteractionService.Interactions.Reader.Count);
}
[Fact]
public async Task DeployAsync_WithParametersInEnvironmentVariables_DiscoversAndPromptsForParameters()
{
// Arrange
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, step: WellKnownPipelineSteps.Deploy);
var testInteractionService = new TestInteractionService();
ConfigureTestServices(builder, interactionService: testInteractionService, bicepProvisioner: new NoOpBicepProvisioner());
// Create a parameter that will be referenced in environment variables but not added to the model
var dependentParam = new ParameterResource("dependent-param", p => throw new MissingParameterValueException("Should be prompted"), secret: false);
// Create a container that references the parameter in its environment variables
var container = builder.AddContainer("test-container", "test-image")
.WithEnvironment("DEPENDENT_VALUE", dependentParam);
builder.AddAzureEnvironment();
// Act
using var app = builder.Build();
var runTask = Task.Run(app.Run);
// Wait for the parameter inputs interaction (no notification in publish mode)
var parameterInputs = await testInteractionService.Interactions.Reader.ReadAsync();
Assert.Equal("Set unresolved parameters", parameterInputs.Title);
// Verify the dependent parameter is discovered and prompted for (should not include save to secrets option in publish mode)
Assert.Collection(parameterInputs.Inputs,
input =>
{
Assert.Equal("dependent-param", input.Label);
Assert.Equal(InputType.Text, input.InputType);
Assert.Equal("Enter value for dependent-param", input.Placeholder);
});
// Complete the parameter inputs interaction
parameterInputs.Inputs[0].Value = "discovered-param-value";
parameterInputs.CompletionTcs.SetResult(InteractionResult.Ok(parameterInputs.Inputs));
// Wait for the run task to complete (or timeout)
await runTask.WaitAsync(TimeSpan.FromSeconds(10));
var setValue = await dependentParam.GetValueAsync(default);
Assert.Equal("discovered-param-value", setValue);
}
[Fact]
public async Task DeployAsync_WithParametersInArguments_DiscoversAndPromptsForParameters()
{
// Arrange
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, step: WellKnownPipelineSteps.Deploy);
var testInteractionService = new TestInteractionService();
ConfigureTestServices(builder, interactionService: testInteractionService, bicepProvisioner: new NoOpBicepProvisioner());
// Create a parameter that will be referenced in command line arguments but not added to the model
var portParam = new ParameterResource("app-port", p => throw new MissingParameterValueException("Should be prompted"), secret: false);
// Create a container that references the parameter in its command line arguments
var container = builder.AddContainer("test-container", "test-image")
.WithArgs("--port", portParam, "--verbose");
builder.AddAzureEnvironment();
// Act
using var app = builder.Build();
var runTask = Task.Run(app.Run);
// Wait for the parameter inputs interaction (no notification in publish mode)
var parameterInputs = await testInteractionService.Interactions.Reader.ReadAsync();
Assert.Equal("Set unresolved parameters", parameterInputs.Title);
// Verify the dependent parameter is discovered and prompted for (should not include save to secrets option in publish mode)
Assert.Collection(parameterInputs.Inputs,
input =>
{
Assert.Equal("app-port", input.Label);
Assert.Equal(InputType.Text, input.InputType);
Assert.Equal("Enter value for app-port", input.Placeholder);
});
// Complete the parameter inputs interaction
parameterInputs.Inputs[0].Value = "8080";
parameterInputs.CompletionTcs.SetResult(InteractionResult.Ok(parameterInputs.Inputs));
// Wait for the run task to complete (or timeout)
await runTask.WaitAsync(TimeSpan.FromSeconds(10));
var setValue = await portParam.GetValueAsync(default);
Assert.Equal("8080", setValue);
}
[Fact]
public async Task DeployAsync_WithAzureFunctionsProject_Works()
{
// Arrange
var mockProcessRunner = new MockProcessRunner();
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, step: WellKnownPipelineSteps.Deploy);
var deploymentOutputsProvider = (string deploymentName) => deploymentName switch
{
string name when name.StartsWith("env") => 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" }
},
string name when name.StartsWith("funcstorage") => new Dictionary<string, object>
{
["name"] = new { type = "String", value = "testfuncstorage" },
["blobEndpoint"] = new { type = "String", value = "https://testfuncstorage.blob.core.windows.net/" },
["queueEndpoint"] = new { type = "String", value = "https://testfuncstorage.queue.core.windows.net/" },
["tableEndpoint"] = new { type = "String", value = "https://testfuncstorage.table.core.windows.net/" }
},
string name when name.StartsWith("hoststorage") => new Dictionary<string, object>
{
["name"] = new { type = "String", value = "testhoststorage" },
["blobEndpoint"] = new { type = "String", value = "https://testhoststorage.blob.core.windows.net/" },
["queueEndpoint"] = new { type = "String", value = "https://testhoststorage.queue.core.windows.net/" },
["tableEndpoint"] = new { type = "String", value = "https://testhoststorage.table.core.windows.net/" }
},
string name when name.StartsWith("funcapp-identity") => new Dictionary<string, object>
{
["principalId"] = new { type = "String", value = "/subscriptions/test/resourceGroups/test-rg/providers/Microsoft.ManagedIdentity/userAssignedIdentities/test-identity" },
},
string name when name.StartsWith("funcapp") => new Dictionary<string, object>
{
["identity_id"] = new { type = "String", value = "/subscriptions/test/resourceGroups/test-rg/providers/Microsoft.ManagedIdentity/userAssignedIdentities/test-identity" },
["identity_clientId"] = new { type = "String", value = "test-client-id" }
},
_ => []
};
var armClientProvider = ProvisioningTestHelpers.CreateArmClientProvider(deploymentOutputsProvider);
ConfigureTestServices(builder, armClientProvider: armClientProvider, processRunner: mockProcessRunner);
var containerAppEnv = builder.AddAzureContainerAppEnvironment("env");
var azureEnv = builder.AddAzureEnvironment();
// Add Azure Storage for the Functions project
var storage = builder.AddAzureStorage("funcstorage");
var hostStorage = builder.AddAzureStorage("hoststorage");
var blobs = storage.AddBlobs("blobs");
var funcApp = builder.AddAzureFunctionsProject<TestFunctionsProject>("funcapp")
.WithReference(blobs)
.WithHostStorage(hostStorage);
// Act
using var app = builder.Build();
await app.StartAsync();
await app.WaitForShutdownAsync();
// Assert that container environment outputs are propagated
Assert.Equal("testregistry", containerAppEnv.Resource.Outputs["AZURE_CONTAINER_REGISTRY_NAME"]);
Assert.Equal("testregistry.azurecr.io", containerAppEnv.Resource.Outputs["AZURE_CONTAINER_REGISTRY_ENDPOINT"]);
Assert.Equal("/subscriptions/test/resourceGroups/test-rg/providers/Microsoft.ManagedIdentity/userAssignedIdentities/test-identity", containerAppEnv.Resource.Outputs["AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID"]);
Assert.Equal("test.westus.azurecontainerapps.io", containerAppEnv.Resource.Outputs["AZURE_CONTAINER_APPS_ENVIRONMENT_DEFAULT_DOMAIN"]);
Assert.Equal("/subscriptions/test/resourceGroups/test-rg/providers/Microsoft.App/managedEnvironments/testenv", containerAppEnv.Resource.Outputs["AZURE_CONTAINER_APPS_ENVIRONMENT_ID"]);
// Assert that funcapp outputs are propagated
var funcAppDeployment = Assert.IsAssignableFrom<AzureProvisioningResource>(funcApp.Resource.GetDeploymentTargetAnnotation()?.DeploymentTarget);
Assert.NotNull(funcAppDeployment);
Assert.Equal(await ((BicepOutputReference)funcAppDeployment.Parameters["env_outputs_azure_container_apps_environment_default_domain"]!).GetValueAsync(), containerAppEnv.Resource.Outputs["AZURE_CONTAINER_APPS_ENVIRONMENT_DEFAULT_DOMAIN"]);
Assert.Equal(await ((BicepOutputReference)funcAppDeployment.Parameters["env_outputs_azure_container_apps_environment_id"]!).GetValueAsync(), containerAppEnv.Resource.Outputs["AZURE_CONTAINER_APPS_ENVIRONMENT_ID"]);
Assert.Equal("https://testfuncstorage.blob.core.windows.net/", await ((BicepOutputReference)funcAppDeployment.Parameters["funcstorage_outputs_blobendpoint"]!).GetValueAsync());
Assert.Equal("https://testhoststorage.blob.core.windows.net/", await ((BicepOutputReference)funcAppDeployment.Parameters["hoststorage_outputs_blobendpoint"]!).GetValueAsync());
// Assert - Verify ACR login command was called since Functions image needs to be built and pushed
Assert.Contains(mockProcessRunner.ExecutedCommands,
cmd => cmd.ExecutablePath.Contains("az") &&
cmd.Arguments == "acr login --name testregistry");
// Assert - Verify MockImageBuilder tag and push methods were called
var mockImageBuilder = app.Services.GetRequiredService<IResourceContainerImageBuilder>() as MockImageBuilder;
Assert.NotNull(mockImageBuilder);
Assert.True(mockImageBuilder.TagImageCalled);
Assert.True(mockImageBuilder.PushImageCalled);
// Verify specific tag call was made (local "funcapp" to target in testregistry with deployment tag)
Assert.Contains(mockImageBuilder.TagImageCalls, call =>
call.localImageName == "funcapp" &&
call.targetImageName.StartsWith("testregistry.azurecr.io/") &&
call.targetImageName.Contains("aspire-deploy-"));
// Verify specific push call was made with deployment tag
Assert.Contains(mockImageBuilder.PushImageCalls, imageName =>
imageName.StartsWith("testregistry.azurecr.io/") &&
imageName.Contains("aspire-deploy-"));
}
private static void ConfigureTestServices(IDistributedApplicationTestingBuilder builder,
IInteractionService? interactionService = null,
IBicepProvisioner? bicepProvisioner = null,
IArmClientProvider? armClientProvider = null,
MockProcessRunner? processRunner = null,
IPipelineActivityReporter? activityReporter = null,
bool setDefaultProvisioningOptions = true)
{
var options = setDefaultProvisioningOptions ? ProvisioningTestHelpers.CreateOptions() : ProvisioningTestHelpers.CreateOptions(null, null, null);
var environment = ProvisioningTestHelpers.CreateEnvironment();
var logger = ProvisioningTestHelpers.CreateLogger();
armClientProvider ??= ProvisioningTestHelpers.CreateArmClientProvider();
var userPrincipalProvider = ProvisioningTestHelpers.CreateUserPrincipalProvider();
var tokenCredentialProvider = ProvisioningTestHelpers.CreateTokenCredentialProvider();
builder.Services.AddSingleton(armClientProvider);
builder.Services.AddSingleton(userPrincipalProvider);
builder.Services.AddSingleton(tokenCredentialProvider);
builder.Services.AddSingleton(environment);
builder.Services.AddSingleton(logger);
builder.Services.AddSingleton(options);
if (interactionService is not null)
{
builder.Services.AddSingleton(interactionService);
}
if (activityReporter is not null)
{
builder.Services.AddSingleton(activityReporter);
}
builder.Services.AddSingleton<IProvisioningContextProvider, PublishModeProvisioningContextProvider>();
builder.Services.AddSingleton<IDeploymentStateManager, NoOpDeploymentStateManager>();
if (bicepProvisioner is not null)
{
builder.Services.AddSingleton(bicepProvisioner);
}
builder.Services.AddSingleton<IProcessRunner>(processRunner ?? new MockProcessRunner());
builder.Services.AddSingleton<IResourceContainerImageBuilder, MockImageBuilder>();
}
private sealed class NoOpDeploymentStateManager : IDeploymentStateManager
{
public string? StateFilePath => null;
public Task<DeploymentStateSection> AcquireSectionAsync(string sectionName, CancellationToken cancellationToken = default)
=> Task.FromResult(new DeploymentStateSection(sectionName, [], 0));
public Task SaveSectionAsync(DeploymentStateSection section, CancellationToken cancellationToken = default) => Task.CompletedTask;
}
private sealed class NoOpBicepProvisioner : IBicepProvisioner
{
public Task<bool> ConfigureResourceAsync(IConfiguration configuration, AzureBicepResource resource, CancellationToken cancellationToken)
{
return Task.FromResult(true);
}
public Task GetOrCreateResourceAsync(AzureBicepResource resource, ProvisioningContext context, CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
}
private sealed class Project : IProjectMetadata
{
public string ProjectPath => "project";
}
private sealed class TestFunctionsProject : IProjectMetadata
{
public string ProjectPath => "functions-project";
public LaunchSettings LaunchSettings => new()
{
Profiles = new Dictionary<string, LaunchProfile>
{
["funcapp"] = new()
{
CommandLineArgs = "--port 7071",
LaunchBrowser = false,
}
}
};
}
[Fact]
public async Task DeployAsync_FirstDeployment_SavesStateToFile()
{
var appHostSha = "testsha1first";
using var builder = TestDistributedApplicationBuilder.Create(
$"AppHost:Operation=publish",
$"Pipeline:OutputPath=./",
$"Pipeline:Step=deploy",
$"AppHostSha={appHostSha}");
ConfigureTestServicesWithFileDeploymentStateManager(builder, bicepProvisioner: new NoOpBicepProvisioner());
builder.AddAzureEnvironment();
using var app = builder.Build();
var deploymentStateManager = app.Services.GetRequiredService<IDeploymentStateManager>();
var deploymentStatePath = deploymentStateManager.StateFilePath;
Assert.NotNull(deploymentStatePath);
if (File.Exists(deploymentStatePath))
{
File.Delete(deploymentStatePath);
}
await app.StartAsync();
await app.WaitForShutdownAsync();
Assert.True(File.Exists(deploymentStatePath));
var stateContent = await File.ReadAllTextAsync(deploymentStatePath);
var stateJson = JsonNode.Parse(stateContent);
Assert.NotNull(stateJson);
Assert.True(stateJson.AsObject().ContainsKey("Azure:SubscriptionId"));
if (File.Exists(deploymentStatePath))
{
File.Delete(deploymentStatePath);
}
}
[Fact]
public async Task DeployAsync_WithCachedDeploymentState_LoadsFromCache()
{
var appHostSha = "testsha2cache";
var deploymentStatePath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
".aspire",
"deployments",
appHostSha,
$"Production.json"
);
Directory.CreateDirectory(Path.GetDirectoryName(deploymentStatePath)!);
var cachedState = new JsonObject
{
["Azure:SubscriptionId"] = "cached-sub-12345678-1234-1234-1234-123456789012",
["Azure:Location"] = "westus2",
["Azure:ResourceGroup"] = "cached-rg-test"
};
await File.WriteAllTextAsync(deploymentStatePath, cachedState.ToJsonString());
using var builder = TestDistributedApplicationBuilder.Create(
$"Publishing:Publisher=default",
$"Publishing:OutputPath=./",
$"Publishing:Deploy=true",
$"AppHostSha={appHostSha}");
ConfigureTestServicesWithFileDeploymentStateManager(builder, bicepProvisioner: new NoOpBicepProvisioner());
using var app = builder.Build();
// Verify that the cached state was loaded into configuration
Assert.Equal("cached-sub-12345678-1234-1234-1234-123456789012", builder.Configuration["Azure:SubscriptionId"]);
Assert.Equal("westus2", builder.Configuration["Azure:Location"]);
Assert.Equal("cached-rg-test", builder.Configuration["Azure:ResourceGroup"]);
builder.AddAzureEnvironment();
await app.StartAsync();
await app.WaitForShutdownAsync();
// Verify that the state file still exists after deployment
Assert.True(File.Exists(deploymentStatePath));
if (File.Exists(deploymentStatePath))
{
File.Delete(deploymentStatePath);
}
}
[Fact]
public async Task DeployAsync_WithClearCacheFlag_DoesNotSaveState()
{
var appHostSha = "testsha3clear";
using var builder = TestDistributedApplicationBuilder.Create(
$"AppHost:Operation=publish",
$"Pipeline:OutputPath=./",
$"Pipeline:ClearCache=true",
$"AppHostSha={appHostSha}");
ConfigureTestServicesWithFileDeploymentStateManager(builder, bicepProvisioner: new NoOpBicepProvisioner());
builder.AddAzureEnvironment();
using var app = builder.Build();
var deploymentStateManager = app.Services.GetRequiredService<IDeploymentStateManager>();
var deploymentStatePath = deploymentStateManager.StateFilePath;
Assert.NotNull(deploymentStatePath);
if (File.Exists(deploymentStatePath))
{
File.Delete(deploymentStatePath);
}
await app.StartAsync();
await app.WaitForShutdownAsync();
Assert.False(File.Exists(deploymentStatePath));
}
[Fact]
public async Task DeployAsync_WithStagingEnvironment_UsesStagingStateFile()
{
var appHostSha = "testsha4stage";
using var builder = TestDistributedApplicationBuilder.Create(
"AppHost:Operation=publish",
$"Pipeline:OutputPath=./",
$"Pipeline:Step=deploy",
$"AppHostSha={appHostSha}");
ConfigureTestServicesWithFileDeploymentStateManager(builder, bicepProvisioner: new NoOpBicepProvisioner(), environmentName: "Staging");
builder.AddAzureEnvironment();
using var app = builder.Build();
var deploymentStateManager = app.Services.GetRequiredService<IDeploymentStateManager>();
var stagingStatePath = deploymentStateManager.StateFilePath;
Assert.NotNull(stagingStatePath);
Assert.EndsWith("staging.json", stagingStatePath);
if (File.Exists(stagingStatePath))
{
File.Delete(stagingStatePath);
}
await app.StartAsync();
await app.WaitForShutdownAsync();
Assert.True(File.Exists(stagingStatePath));
var stateContent = await File.ReadAllTextAsync(stagingStatePath);
var stateJson = JsonNode.Parse(stateContent);
Assert.NotNull(stateJson);
if (File.Exists(stagingStatePath))
{
File.Delete(stagingStatePath);
}
}
private static void ConfigureTestServicesWithFileDeploymentStateManager(
IDistributedApplicationTestingBuilder builder,
IBicepProvisioner? bicepProvisioner = null,
string? environmentName = null)
{
var options = ProvisioningTestHelpers.CreateOptions();
var environment = new TestHostEnvironment
{
ApplicationName = "TestApp",
EnvironmentName = environmentName ?? "Test"
};
var logger = ProvisioningTestHelpers.CreateLogger();
var armClientProvider = ProvisioningTestHelpers.CreateArmClientProvider();
var userPrincipalProvider = ProvisioningTestHelpers.CreateUserPrincipalProvider();
var tokenCredentialProvider = ProvisioningTestHelpers.CreateTokenCredentialProvider();
builder.Services.AddSingleton<IHostEnvironment>(environment);
builder.Services.AddSingleton(armClientProvider);
builder.Services.AddSingleton(userPrincipalProvider);
builder.Services.AddSingleton(tokenCredentialProvider);
builder.Services.AddSingleton(logger);
builder.Services.AddSingleton(options);
builder.Services.AddSingleton<IProvisioningContextProvider, PublishModeProvisioningContextProvider>();
builder.Services.AddSingleton<IDeploymentStateManager, FileDeploymentStateManager>();
if (bicepProvisioner is not null)
{
builder.Services.AddSingleton(bicepProvisioner);
}
builder.Services.AddSingleton<IProcessRunner>(new MockProcessRunner());
builder.Services.AddSingleton<IResourceContainerImageBuilder, MockImageBuilder>();
}
private sealed class TestPublishingActivityReporter : IPipelineActivityReporter
{
public bool CompletePublishCalled { get; private set; }
public string? CompletionMessage { get; private set; }
public List<string> CreatedSteps { get; } = [];
public List<(string StepTitle, string TaskStatusText)> CreatedTasks { get; } = [];
public List<(string StepTitle, string CompletionText, CompletionState CompletionState)> CompletedSteps { get; } = [];
public List<(string TaskStatusText, string? CompletionMessage, CompletionState CompletionState)> CompletedTasks { get; } = [];
public List<(string TaskStatusText, string StatusText)> UpdatedTasks { get; } = [];
public List<(string StepTitle, LogLevel LogLevel, string Message)> LoggedMessages { get; } = [];
public Task CompletePublishAsync(string? completionMessage = null, CompletionState? completionState = null, bool isDeploy = false, CancellationToken cancellationToken = default)
{
CompletePublishCalled = true;
CompletionMessage = completionMessage;
return Task.CompletedTask;
}
public Task<IReportingStep> CreateStepAsync(string title, CancellationToken cancellationToken = default)
{
CreatedSteps.Add(title);
return Task.FromResult<IReportingStep>(new TestReportingStep(this, title));
}
private sealed class TestReportingStep : IReportingStep
{
private readonly TestPublishingActivityReporter _reporter;
private readonly string _title;
public TestReportingStep(TestPublishingActivityReporter reporter, string title)
{
_reporter = reporter;
_title = title;
}
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
public Task CompleteAsync(string completionText, CompletionState completionState = CompletionState.Completed, CancellationToken cancellationToken = default)
{
_reporter.CompletedSteps.Add((_title, completionText, completionState));
return Task.CompletedTask;
}
public Task<IReportingTask> CreateTaskAsync(string statusText, CancellationToken cancellationToken = default)
{
_reporter.CreatedTasks.Add((_title, statusText));
return Task.FromResult<IReportingTask>(new TestReportingTask(_reporter, statusText));
}
public void Log(LogLevel logLevel, string message, bool enableMarkdown)
{
_reporter.LoggedMessages.Add((_title, logLevel, message));
}
}
private sealed class TestReportingTask : IReportingTask
{
private readonly TestPublishingActivityReporter _reporter;
private readonly string _initialStatusText;
public TestReportingTask(TestPublishingActivityReporter reporter, string initialStatusText)
{
_reporter = reporter;
_initialStatusText = initialStatusText;
}
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
public Task CompleteAsync(string? completionMessage = null, CompletionState completionState = CompletionState.Completed, CancellationToken cancellationToken = default)
{
_reporter.CompletedTasks.Add((_initialStatusText, completionMessage, completionState));
return Task.CompletedTask;
}
public Task UpdateAsync(string statusText, CancellationToken cancellationToken = default)
{
_reporter.UpdatedTasks.Add((_initialStatusText, statusText));
return Task.CompletedTask;
}
}
}
}
|