// 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 CS0618 // Retain legacy ProjectResource Compose projection alongside executable/V2 deployment coverage.
#pragma warning disable ASPIREPIPELINES003
#pragma warning disable ASPIRECONTAINERRUNTIME001
using System.Text.RegularExpressions;
using Aspire.Hosting.ApplicationModel;
using Aspire.Hosting.Docker.Resources.ComposeNodes;
using Aspire.Hosting.Pipelines;
using Aspire.Hosting.Publishing;
using Aspire.Hosting.Tests;
using Aspire.Hosting.Tests.Publishing;
using Aspire.Hosting.Utils;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
namespace Aspire.Hosting.Docker.Tests;
public class DockerComposePublisherTests(ITestOutputHelper outputHelper)
{
[Theory]
[InlineData(false, false)]
[InlineData(true, false)]
[InlineData(false, true)]
[InlineData(true, true)]
public async Task BrowserOriginInputsAvoidDeclaredParameterNames(bool originFirst, bool normalizedCollision)
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, workspace.Path, step: "prepare-env");
builder.Services.AddSingleton<IResourceContainerImageManager, MockImageBuilder>();
var environment = builder.AddDockerComposeEnvironment("env").WithDashboard(false);
var declaredName = normalizedCollision ? "WEB-HTTP-BROWSERORIGINS" : "web-http-BrowserOrigins";
var declared = builder.AddParameter(declaredName, "https://override.example");
var reservedSuffix = builder.AddParameter("web-http-BrowserOrigins-1", "reserved");
var web = builder.AddContainer("web", "image").WithHttpEndpoint(port: 8080, targetPort: 80).WithExternalHttpEndpoints();
var consumer = builder.AddContainer("consumer", "image");
if (originFirst)
{
consumer.WithEnvironment("ORIGIN", web.GetEndpoint("http").BrowserOrigins)
.WithEnvironment("DECLARED", declared);
}
else
{
consumer.WithEnvironment("DECLARED", declared)
.WithEnvironment("ORIGIN", web.GetEndpoint("http").BrowserOrigins);
}
consumer.WithEnvironment("REPEATED", web.GetEndpoint("http").BrowserOrigins)
.WithEnvironment("RESERVED", reservedSuffix);
using var app = builder.Build();
app.Run();
var captured = environment.Resource.CapturedEnvironmentVariables;
Assert.Equal(3, captured.Count);
Assert.Same(declared.Resource, captured["WEB_HTTP_BROWSERORIGINS"].Source);
Assert.Same(reservedSuffix.Resource, captured["WEB_HTTP_BROWSERORIGINS_1"].Source);
var generated = Assert.IsType<ParameterResource>(captured["WEB_HTTP_BROWSERORIGINS_2"].Source);
Assert.Equal("web-http-BrowserOrigins-2", generated.Name);
Assert.Equal("https://override.example", await declared.Resource.GetValueAsync(default));
Assert.Equal("http://localhost:8080", await generated.GetValueAsync(default));
await Verify(await File.ReadAllTextAsync(Path.Combine(workspace.Path, ".env.Production")), "env")
.UseParameters(originFirst, normalizedCollision);
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task BrowserOriginInputsKeepDistinctEndpointIdentities(bool longNames)
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, workspace.Path);
builder.Services.AddSingleton<IResourceContainerImageManager, MockImageBuilder>();
var environment = builder.AddDockerComposeEnvironment("env").WithDashboard(false);
var prefix = longNames ? new string('a', 45) : "a";
var first = builder.AddContainer($"{prefix}-b", "image").WithHttpEndpoint(name: "c", port: 8080, targetPort: 80).WithExternalHttpEndpoints();
var second = builder.AddContainer(prefix, "image").WithHttpEndpoint(name: "b-c", port: 8081, targetPort: 80).WithExternalHttpEndpoints();
builder.AddContainer("consumer", "image")
.WithEnvironment("FIRST", first.GetEndpoint("c").BrowserOrigins)
.WithEnvironment("SECOND", second.GetEndpoint("b-c").BrowserOrigins)
.WithEnvironment("REPEATED", first.GetEndpoint("c").BrowserOrigins);
using var app = builder.Build();
app.Run();
var inputs = environment.Resource.CapturedEnvironmentVariables.Values
.Select(value => Assert.IsType<ParameterResource>(value.Source)).ToArray();
var values = await Task.WhenAll(inputs.Select(async parameter => await parameter.GetValueAsync(default)));
Assert.Equal(["http://localhost:8080", "http://localhost:8081"], values);
Assert.All(inputs, parameter => Assert.InRange(parameter.Name.Length, 1, 64));
await Verify(await File.ReadAllTextAsync(Path.Combine(workspace.Path, "docker-compose.yaml")), "yaml").UseParameters(longNames);
}
[Theory]
[InlineData("http", false, EndpointProperty.BrowserOrigins)]
[InlineData("tcp", true, EndpointProperty.BrowserOrigins)]
[InlineData("tcp", false, EndpointProperty.BrowserOrigins)]
[InlineData("http", false, EndpointProperty.PrimaryBrowserOrigin)]
[InlineData("tcp", true, EndpointProperty.PrimaryBrowserOrigin)]
[InlineData("tcp", false, EndpointProperty.PrimaryBrowserOrigin)]
public async Task BrowserOriginsRejectInternalOrNonHttpEndpoints(string scheme, bool external, EndpointProperty property)
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, workspace.Path);
builder.Services.AddSingleton<IResourceContainerImageManager, MockImageBuilder>();
var environment = builder.AddDockerComposeEnvironment("env").WithDashboard(false);
var web = builder.AddContainer("web", "image")
.WithEndpoint(name: "endpoint", scheme: scheme, targetPort: 8080, isExternal: external);
var consumer = builder.AddContainer("consumer", "image").WithEnvironment("ORIGINS", web.GetEndpoint("endpoint").Property(property));
using var app = builder.Build();
var context = new DockerComposeEnvironmentContext(environment.Resource, NullLogger.Instance);
await context.CreateDockerComposeServiceResourceAsync(web.Resource, builder.ExecutionContext, default);
var target = await context.CreateDockerComposeServiceResourceAsync(consumer.Resource, builder.ExecutionContext, default);
var error = await Assert.ThrowsAsync<NotSupportedException>(() => target.BuildComposeServiceAsync(builder.Configuration));
Assert.Equal(
"Docker Compose cannot provide browser origins for endpoint 'endpoint' on resource 'web' because it is not an external HTTP(S) endpoint. " +
"Reference an external HTTP(S) endpoint or supply an explicit origin parameter.", error.GetBaseException().Message);
Assert.Empty(environment.Resource.CapturedEnvironmentVariables);
}
[Fact]
public void BrowserOriginWithoutPublishedPortRequiresInput()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, workspace.Path);
builder.Services.AddSingleton<IResourceContainerImageManager, MockImageBuilder>();
var environment = builder.AddDockerComposeEnvironment("env").WithDashboard(false);
var web = builder.AddContainer("web", "image").WithHttpEndpoint(targetPort: 80).WithExternalHttpEndpoints();
builder.AddContainer("consumer", "image").WithEnvironment("ORIGIN", web.GetEndpoint("http").BrowserOrigins);
using var app = builder.Build();
app.Run();
var parameter = Assert.IsType<ParameterResource>(Assert.Single(environment.Resource.CapturedEnvironmentVariables).Value.Source);
Assert.Null(parameter.Default);
Assert.True(parameter.Required);
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task BrowserOriginInputsDefaultToPublishedLocalhost(bool overridden)
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, workspace.Path, step: "prepare-env");
builder.Services.AddSingleton<IResourceContainerImageManager, MockImageBuilder>();
var environment = builder.AddDockerComposeEnvironment("env").WithDashboard(false);
var web = builder.AddContainer("web", "image").WithHttpEndpoint(port: 8080, targetPort: 80).WithExternalHttpEndpoints();
builder.AddContainer("consumer", "image")
.WithEnvironment("ORIGIN", web.GetEndpoint("http").PrimaryBrowserOrigin)
.WithEnvironment("ORIGINS", web.GetEndpoint("http").BrowserOrigins);
if (overridden)
{
builder.Configuration["Parameters:web-http-PrimaryBrowserOrigin"] = "https://primary.example";
builder.Configuration["Parameters:web-http-BrowserOrigins"] = "https://public.example";
}
using var app = builder.Build();
app.Run();
var inputs = environment.Resource.CapturedEnvironmentVariables.Values.Select(value => Assert.IsType<ParameterResource>(value.Source)).ToArray();
var primary = Assert.Single(inputs, input => input.Name == "web-http-PrimaryBrowserOrigin");
var origins = Assert.Single(inputs, input => input.Name == "web-http-BrowserOrigins");
Assert.Equal("http://localhost:8080", primary.Default!.GetDefaultValue());
Assert.Equal(overridden ? "https://primary.example" : "http://localhost:8080", await primary.GetValueAsync(default));
Assert.Equal(overridden ? "https://public.example" : "http://localhost:8080", await origins.GetValueAsync(default));
await Verify(await File.ReadAllTextAsync(Path.Combine(workspace.Path, ".env.Production")), "env").UseParameters(overridden);
}
[Fact]
public async Task BrowserUrlProjectionUsesPublisherExpression()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, workspace.Path, step: "prepare-env");
builder.Services.AddSingleton<IDeploymentStateManager>(new InMemoryDeploymentStateManager());
builder.Services.AddSingleton<IResourceContainerImageManager, MockImageBuilder>();
var environment = builder.AddDockerComposeEnvironment("env");
builder.Configuration["Parameters:frontend-http-BrowserOrigins"] = "https://public.example,https://alias.example";
var frontend = builder.AddContainer("frontend", "nginx:alpine").WithHttpEndpoint(port: 8080, targetPort: 8080).WithExternalHttpEndpoints();
var origin = frontend.GetEndpoint("HTTP").BrowserOrigins;
builder.AddContainer("backend", "nginx:alpine")
.WithEnvironment("ORIGIN", origin)
.WithEnvironment("ORIGINS", frontend.GetEndpoint("http").BrowserOrigins)
.WithEnvironment("CONFIG", ReferenceExpression.Create($"origin={origin}"));
using var app = builder.Build();
app.Run();
var inputs = environment.Resource.CapturedEnvironmentVariables.Values.ToArray();
Assert.Single(inputs);
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var values = await Task.WhenAll(inputs.Select(async input =>
await Assert.IsType<ParameterResource>(input.Source).GetValueAsync(cts.Token)));
Assert.Equal(["https://public.example,https://alias.example"], values);
await Verify(await File.ReadAllTextAsync(Path.Combine(workspace.Path, "docker-compose.yaml")), "yaml")
.AppendContentAsFile(await File.ReadAllTextAsync(Path.Combine(workspace.Path, ".env")), "env")
.AppendContentAsFile(await File.ReadAllTextAsync(Path.Combine(workspace.Path, ".env.Production")), "env");
}
[Fact]
public async Task PublishAsync_GeneratesValidDockerComposeFile()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
// Arrange
var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, workspace.Path);
builder.Services.AddSingleton<IResourceContainerImageManager, MockImageBuilder>();
builder.AddDockerComposeEnvironment("docker-compose");
var param0 = builder.AddParameter("param0");
var param1 = builder.AddParameter("param1", secret: true);
var param2 = builder.AddParameter("param2", "default", publishValueAsDefault: true);
var cs = builder.AddConnectionString("cs", ReferenceExpression.Create($"Url={param0}, Secret={param1}"));
// Add a container to the application
var redis = builder.AddContainer("cache", "redis")
.WithEntrypoint("/bin/sh")
.WithHttpEndpoint(name: "h2", port: 5000, targetPort: 5001)
.WithHttpEndpoint(env: "REDIS_PORT")
.WithArgs("-c", "hello $MSG")
.WithEnvironment("MSG", "world")
.WithContainerFiles("/usr/local/share", [
new ContainerFile
{
Name = "redis.conf",
Contents = "hello world",
},
new ContainerDirectory
{
Name = "folder",
Entries = [
new ContainerFile
{
Name = "file.sh",
SourcePath = "./hello.sh",
Owner = 1000,
Group = 1000,
Mode = UnixFileMode.UserExecute | UnixFileMode.UserWrite | UnixFileMode.UserRead,
},
],
},
])
.WithEnvironment(context =>
{
var resource = (IResourceWithEndpoints)context.Resource;
context.EnvironmentVariables["TP"] = resource.GetEndpoint("http").Property(EndpointProperty.TargetPort);
context.EnvironmentVariables["TPH2"] = resource.GetEndpoint("h2").Property(EndpointProperty.TargetPort);
});
var migration = builder.AddContainer("something", "dummy/migration:latest")
.WithContainerName("cn");
var api = builder.AddContainer("myapp", "mcr.microsoft.com/dotnet/aspnet:8.0")
.WithEnvironment("ASPNETCORE_ENVIRONMENT", "Development")
.WithHttpEndpoint(env: "PORT")
.WithEnvironment("param0", param0)
.WithEnvironment("param1", param1)
.WithEnvironment("param2", param2)
.WithReference(cs)
.WithArgs("--cs", cs.Resource)
.WaitFor(redis)
.WaitForCompletion(migration)
.WaitFor(param0)
.WithOtlpExporter();
builder.AddProject(
"project1",
"..\\TestingAppHost1\\TestingAppHost1.MyWebApp\\TestingAppHost1.MyWebApp.csproj",
launchProfileName: null)
.WithReference(api.GetEndpoint("http"));
var app = builder.Build();
// Act
app.Run();
// Assert
var composePath = Path.Combine(workspace.Path, "docker-compose.yaml");
var envPath = Path.Combine(workspace.Path, ".env");
Assert.True(File.Exists(composePath));
Assert.True(File.Exists(envPath));
await Verify(File.ReadAllText(composePath), "yaml")
.AppendContentAsFile(File.ReadAllText(envPath), "env");
}
[Fact]
public void ConnectionStringNamesWithHyphens_PreserveBothAliases()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, workspace.Path);
builder.Services.AddSingleton<IResourceContainerImageManager, MockImageBuilder>();
builder.AddDockerComposeEnvironment("docker-compose");
var connection = builder.AddConnectionString("my-db", ReferenceExpression.Create($"Host=example"));
builder.AddContainer("api", "myimage")
.WithReference(connection);
using var app = builder.Build();
app.Run();
var compose = File.ReadAllText(Path.Combine(workspace.Path, "docker-compose.yaml"));
var aliases = Regex.Matches(compose, @"(?m)^\s+(ConnectionStrings__[^:]+):")
.Select(static match => match.Groups[1].Value)
.Order(StringComparer.Ordinal);
Assert.Equal(
["ConnectionStrings__my-db", "ConnectionStrings__my_db"],
aliases);
}
[Fact]
public async Task DockerComposeWithProjectResources()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
// Arrange
var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, workspace.Path);
builder.Services.AddSingleton<IResourceContainerImageManager, MockImageBuilder>();
builder.AddDockerComposeEnvironment("docker-compose");
// Add a project with multiple endpoint combinations
var project = builder.AddProject<TestProjectWithLaunchSettings>("project1")
.WithHttpEndpoint(name: "custom1") // port = null, targetPort = null
.WithHttpEndpoint(port: 7001, name: "custom2") // port = 7001, targetPort = null
.WithHttpEndpoint(targetPort: 7002, name: "custom3") // port = null, targetPort = 7002
.WithHttpEndpoint(port: 7003, targetPort: 7004, name: "custom4"); // port = 7003, targetPort = 7004
builder.AddContainer("api", "reg:api")
.WithReference(project);
var app = builder.Build();
// Act
app.Run();
// Assert
var composePath = Path.Combine(workspace.Path, "docker-compose.yaml");
var envPath = Path.Combine(workspace.Path, ".env");
Assert.True(File.Exists(composePath));
Assert.True(File.Exists(envPath));
await Verify(File.ReadAllText(composePath), "yaml")
.AppendContentAsFile(File.ReadAllText(envPath), "env");
}
[Fact]
public async Task DockerComposeWithDotnetProjectUsesImagePlaceholderAndWaitDependency()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, workspace.Path);
builder.Services.AddSingleton<IResourceContainerImageManager, MockImageBuilder>();
builder.AddDockerComposeEnvironment("docker-compose").WithDashboard(false);
var api = builder.AddDotnetProject("api", "api.csproj", options => options.ExcludeLaunchProfile = true);
builder.AddDotnetProject("worker", "worker.csproj", options => options.ExcludeLaunchProfile = true)
.WaitFor(api);
using var app = builder.Build();
app.Run();
var compose = File.ReadAllText(Path.Combine(workspace.Path, "docker-compose.yaml"));
var environment = File.ReadAllText(Path.Combine(workspace.Path, ".env"));
await Verify(compose, "yaml")
.AppendContentAsFile(environment, "env");
}
[Fact]
public async Task DockerComposeCorrectlyEmitsPortMappings()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, workspace.Path)
.WithTestAndResourceLogging(outputHelper);
builder.Services.AddSingleton<IResourceContainerImageManager, MockImageBuilder>();
builder.AddDockerComposeEnvironment("docker-compose");
builder.AddContainer("resource", "mcr.microsoft.com/dotnet/aspnet:8.0")
.WithEnvironment("ASPNETCORE_ENVIRONMENT", "Development")
.WithHttpEndpoint(env: "HTTP_PORT");
var app = builder.Build();
await app.RunAsync().WaitAsync(TimeSpan.FromSeconds(60));
var composePath = Path.Combine(workspace.Path, "docker-compose.yaml");
Assert.True(File.Exists(composePath));
await Verify(File.ReadAllText(composePath), "yaml");
}
[Fact]
public void DockerComposeDoesNotHandleImageBuildingDuringPublish()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, workspace.Path, step: "publish-docker-compose")
.WithTestAndResourceLogging(outputHelper);
builder.Services.AddSingleton<IResourceContainerImageManager, MockImageBuilder>();
builder.AddDockerComposeEnvironment("docker-compose");
builder.AddContainer("resource", "mcr.microsoft.com/dotnet/aspnet:8.0")
.WithEnvironment("ASPNETCORE_ENVIRONMENT", "Development")
.WithHttpEndpoint(env: "HTTP_PORT");
var app = builder.Build();
var mockImageBuilder = app.Services.GetRequiredService<IResourceContainerImageManager>() as MockImageBuilder;
Assert.NotNull(mockImageBuilder);
app.Run();
var composePath = Path.Combine(workspace.Path, "docker-compose.yaml");
Assert.True(File.Exists(composePath));
Assert.False(mockImageBuilder.BuildImageCalled);
}
[Fact]
public async Task DockerComposeAppliesServiceCustomizations()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, workspace.Path);
builder.Services.AddSingleton<IResourceContainerImageManager, MockImageBuilder>();
var containerNameParam = builder.AddParameter("param-1", "default-name", publishValueAsDefault: true);
builder.AddDockerComposeEnvironment("docker-compose")
.WithProperties(e => e.DefaultNetworkName = "default-network")
.ConfigureComposeFile(file =>
{
file.AddNetwork(new Network { Name = "custom-network", Driver = "host" });
file.Name = "my application";
});
// Add a container to the application
var container = builder.AddContainer("service", "nginx")
.WithEnvironment("ORIGINAL_ENV", "value")
.PublishAsDockerComposeService((serviceResource, composeService) =>
{
// Add a custom label
composeService.Labels["custom-label"] = "test-value";
// Add a custom environment variable
composeService.AddEnvironmentalVariable("CUSTOM_ENV", "custom-value");
// Set a restart policy
composeService.Restart = "always";
composeService.ShmSize = "128mb";
composeService.ContainerName = containerNameParam.AsEnvironmentPlaceholder(serviceResource);
// Add a custom network
composeService.Networks.Add("custom-network");
});
var app = builder.Build();
app.Run();
// Assert
var composePath = Path.Combine(workspace.Path, "docker-compose.yaml");
Assert.True(File.Exists(composePath));
var envPath = Path.Combine(workspace.Path, ".env");
Assert.True(File.Exists(envPath));
await Verify(File.ReadAllText(composePath), "yaml")
.AppendContentAsFile(File.ReadAllText(envPath), "env");
}
[Fact]
public async Task DockerComposeDoesNotOverwriteEnvFileOnPublish()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var envFilePath = Path.Combine(workspace.Path, ".env");
void PublishApp()
{
var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, workspace.Path);
builder.Services.AddSingleton<IResourceContainerImageManager, MockImageBuilder>();
builder.AddDockerComposeEnvironment("docker-compose");
var param = builder.AddParameter("param1");
builder.AddContainer("app", "busybox").WithEnvironment("param1", param);
var app = builder.Build();
app.Run();
}
PublishApp();
Assert.True(File.Exists(envFilePath));
var firstContent = File.ReadAllText(envFilePath).Replace("PARAM1=", "PARAM1=changed");
File.WriteAllText(envFilePath, firstContent);
PublishApp();
Assert.True(File.Exists(envFilePath));
var secondContent = File.ReadAllText(envFilePath);
await Verify(firstContent, "env")
.AppendContentAsFile(secondContent, "env");
}
[Fact]
public async Task DockerComposeAppendsNewKeysToEnvFileOnPublish()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var envFilePath = Path.Combine(workspace.Path, ".env");
void PublishApp(params string[] paramNames)
{
var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, workspace.Path);
builder.Services.AddSingleton<IResourceContainerImageManager, MockImageBuilder>();
builder.AddDockerComposeEnvironment("docker-compose");
var parmeters = paramNames.Select(name => builder.AddParameter(name).Resource).ToArray();
builder.AddContainer("app", "busybox")
.WithEnvironment(context =>
{
foreach (var param in parmeters)
{
context.EnvironmentVariables[param.Name] = param;
}
});
var app = builder.Build();
app.Run();
}
PublishApp(["param1"]);
Assert.True(File.Exists(envFilePath));
var firstContent = File.ReadAllText(envFilePath).Replace("PARAM1=", "PARAM1=changed");
File.WriteAllText(envFilePath, firstContent);
PublishApp(["param1", "param2"]);
Assert.True(File.Exists(envFilePath));
var secondContent = File.ReadAllText(envFilePath);
await Verify(firstContent, "env")
.AppendContentAsFile(secondContent, "env");
}
[Fact]
public async Task DockerComposePreservesMultilineEnvValuesOnPublish()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var envFilePath = Path.Combine(workspace.Path, ".env");
File.WriteAllLines(envFilePath, ["BANNER='hello", "world'", "TAIL=preserved"]);
var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, workspace.Path);
builder.Services.AddSingleton<IResourceContainerImageManager, MockImageBuilder>();
builder.AddDockerComposeEnvironment("docker-compose");
var parameter = builder.AddParameter("param1");
builder.AddContainer("app", "busybox").WithEnvironment("param1", parameter);
using var app = builder.Build();
app.Run();
await Verify(File.ReadAllText(envFilePath), "env");
}
[Fact]
public async Task DockerComposeMapsPortsProperly()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, workspace.Path);
builder.Services.AddSingleton<IResourceContainerImageManager, MockImageBuilder>();
builder.AddDockerComposeEnvironment("docker-compose")
.WithDashboard(false);
var container = builder.AddExecutable("service", "foo", ".")
.PublishAsDockerFile()
.WithHttpEndpoint(env: "PORT");
var app = builder.Build();
app.Run();
var composePath = Path.Combine(workspace.Path, "docker-compose.yaml");
Assert.True(File.Exists(composePath));
var composeFile = File.ReadAllText(composePath);
await Verify(composeFile);
}
[Fact]
public async Task DockerComposeSetsServicePullPolicyFromAnnotation()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, workspace.Path);
builder.Services.AddSingleton<IResourceContainerImageManager, MockImageBuilder>();
builder.AddDockerComposeEnvironment("docker-compose")
.WithDashboard(false);
// Add a container with WithImagePullPolicy annotation - should automatically set pull_policy
builder.AddContainer("api", "my-api:latest")
.WithImagePullPolicy(ImagePullPolicy.Always);
var app = builder.Build();
app.Run();
var composePath = Path.Combine(workspace.Path, "docker-compose.yaml");
Assert.True(File.Exists(composePath));
var composeContent = File.ReadAllText(composePath);
// Verify pull_policy is automatically set from the annotation
Assert.Contains("pull_policy: \"always\"", composeContent);
await Verify(composeContent, "yaml");
}
[Fact]
public async Task DockerComposeSetsServicePullPolicyManually()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, workspace.Path);
builder.Services.AddSingleton<IResourceContainerImageManager, MockImageBuilder>();
builder.AddDockerComposeEnvironment("docker-compose")
.WithDashboard(false);
// Add a container with pull_policy set manually via PublishAsDockerComposeService
builder.AddContainer("api", "my-api:latest")
.PublishAsDockerComposeService((_, composeService) =>
{
composeService.PullPolicy = "always";
});
var app = builder.Build();
app.Run();
var composePath = Path.Combine(workspace.Path, "docker-compose.yaml");
Assert.True(File.Exists(composePath));
var composeContent = File.ReadAllText(composePath);
// Verify pull_policy is in the output
Assert.Contains("pull_policy: \"always\"", composeContent);
await Verify(composeContent, "yaml");
}
[Fact]
public async Task DockerComposeSetsServicePrivilegedManually()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, workspace.Path);
builder.Services.AddSingleton<IResourceContainerImageManager, MockImageBuilder>();
builder.AddDockerComposeEnvironment("docker-compose")
.WithDashboard(false);
builder.AddContainer("api", "my-api:latest")
.PublishAsDockerComposeService((_, composeService) =>
{
composeService.Privileged = true;
});
var app = builder.Build();
app.Run();
var composePath = Path.Combine(workspace.Path, "docker-compose.yaml");
Assert.True(File.Exists(composePath));
var composeContent = File.ReadAllText(composePath);
Assert.Contains("privileged: true", composeContent);
await Verify(composeContent, "yaml");
}
[Fact]
public async Task PublishAsync_WithDashboardEnabled_IncludesDashboardService()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, workspace.Path);
builder.Services.AddSingleton<IResourceContainerImageManager, MockImageBuilder>();
builder.AddDockerComposeEnvironment("docker-compose")
.WithDashboard(); // Dashboard enabled by default
// Add a container with OTLP exporter
builder.AddContainer("api", "my-api")
.WithOtlpExporter();
var app = builder.Build();
app.Run();
var composePath = Path.Combine(workspace.Path, "docker-compose.yaml");
Assert.True(File.Exists(composePath));
var composeContent = File.ReadAllText(composePath);
await Verify(composeContent, "yaml");
}
[Fact]
public async Task PublishAsync_WithDashboard_HonorsRequiredOtlpProtocols()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, workspace.Path);
builder.Services.AddSingleton<IResourceContainerImageManager, MockImageBuilder>();
builder.AddDockerComposeEnvironment("docker-compose")
.WithDashboard();
builder.AddContainer("default", "my-default")
.WithOtlpExporter();
builder.AddContainer("grpc", "my-grpc")
.WithOtlpExporter(OtlpProtocol.Grpc);
builder.AddContainer("http-protobuf", "my-http-protobuf")
.WithOtlpExporter(OtlpProtocol.HttpProtobuf);
builder.AddContainer("http-json", "my-http-json")
.WithOtlpExporter(OtlpProtocol.HttpJson);
var app = builder.Build();
app.Run();
var composePath = Path.Combine(workspace.Path, "docker-compose.yaml");
Assert.True(File.Exists(composePath));
await Verify(File.ReadAllText(composePath), "yaml");
}
[Fact]
public async Task PublishAsync_WithDashboardDisabled_DoesNotIncludeDashboardService()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, workspace.Path);
builder.Services.AddSingleton<IResourceContainerImageManager, MockImageBuilder>();
builder.AddDockerComposeEnvironment("docker-compose")
.WithDashboard(false);
// Add a container with OTLP exporter
builder.AddContainer("api", "my-api")
.WithOtlpExporter();
var app = builder.Build();
app.Run();
var composePath = Path.Combine(workspace.Path, "docker-compose.yaml");
Assert.True(File.Exists(composePath));
var composeContent = File.ReadAllText(composePath);
await Verify(composeContent, "yaml");
}
[Theory]
[InlineData(true)]
[InlineData(false)]
public async Task PublishAsync_DenoNativeOpenTelemetryFollowsInjectedEndpoint(bool dashboardEnabled)
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, workspace.Path);
builder.Services.AddSingleton<IResourceContainerImageManager, MockImageBuilder>();
builder.AddDockerComposeEnvironment("docker-compose")
.WithDashboard(dashboardEnabled);
builder.AddContainer("deno", "denoland/deno")
.WithOtlpExporterIfEndpointAvailable(OtlpProtocol.HttpProtobuf)
.WithOtlpExporterActivationEnvironmentVariable("OTEL_DENO", "true");
using var app = builder.Build();
await app.RunAsync();
var composePath = Path.Combine(workspace.Path, "docker-compose.yaml");
Assert.True(File.Exists(composePath));
var compose = await File.ReadAllTextAsync(composePath);
var hasEndpoint = compose.Contains("OTEL_EXPORTER_OTLP_ENDPOINT", StringComparison.Ordinal);
var hasNativeDenoTelemetry = compose.Contains("OTEL_DENO", StringComparison.Ordinal);
Assert.Equal(dashboardEnabled, hasEndpoint);
Assert.Equal(hasEndpoint, hasNativeDenoTelemetry);
}
[Fact]
public async Task PublishAsync_LastOtlpExporterAnnotationDoesNotApplySupersededActivation()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, workspace.Path);
builder.Services.AddSingleton<IResourceContainerImageManager, MockImageBuilder>();
builder.AddDockerComposeEnvironment("docker-compose")
.WithDashboard();
builder.AddContainer("deno", "denoland/deno")
.WithOtlpExporterIfEndpointAvailable(OtlpProtocol.HttpProtobuf)
.WithOtlpExporterActivationEnvironmentVariable("OTEL_DENO", "true")
.WithOtlpExporter();
using var app = builder.Build();
await app.RunAsync();
var composePath = Path.Combine(workspace.Path, "docker-compose.yaml");
Assert.True(File.Exists(composePath));
var compose = await File.ReadAllTextAsync(composePath);
Assert.Contains("OTEL_EXPORTER_OTLP_ENDPOINT", compose);
Assert.Contains("OTEL_EXPORTER_OTLP_PROTOCOL: \"grpc\"", compose);
Assert.False(compose.Contains("OTEL_DENO", StringComparison.Ordinal));
}
[Fact]
public async Task PublishAsync_WithDashboard_UsesCustomConfiguration()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, workspace.Path);
builder.Services.AddSingleton<IResourceContainerImageManager, MockImageBuilder>();
builder.AddDockerComposeEnvironment("docker-compose")
.WithDashboard(dashboard =>
{
dashboard.WithImage("custom-dashboard:latest")
.WithContainerName("custom-dashboard")
.WithEnvironment("CUSTOM_VAR", "custom-value")
.WithHostPort(8081);
});
var app = builder.Build();
app.Run();
var composePath = Path.Combine(workspace.Path, "docker-compose.yaml");
Assert.True(File.Exists(composePath));
var composeContent = File.ReadAllText(composePath);
await Verify(composeContent, "yaml");
}
[Fact]
public async Task PublishAsync_MultipleResourcesWithOtlp_ConfiguresAllForDashboard()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, workspace.Path);
builder.Services.AddSingleton<IResourceContainerImageManager, MockImageBuilder>();
builder.AddDockerComposeEnvironment("docker-compose")
.WithDashboard();
// Add multiple containers with OTLP exporter
builder.AddContainer("api", "my-api")
.WithOtlpExporter();
builder.AddContainer("worker", "my-worker")
.WithOtlpExporter();
// Add a container without OTLP - should not be configured
builder.AddContainer("database", "postgres");
var app = builder.Build();
app.Run();
var composePath = Path.Combine(workspace.Path, "docker-compose.yaml");
Assert.True(File.Exists(composePath));
var composeContent = File.ReadAllText(composePath);
await Verify(composeContent, "yaml");
}
[Fact]
public async Task PublishAsync_WithDockerfileFactory_WritesDockerfileToOutputFolder()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, workspace.Path);
builder.Services.AddSingleton<IResourceContainerImageManager, MockImageBuilder>();
builder.AddDockerComposeEnvironment("docker-compose");
var dockerfileContent = "FROM alpine:latest\nRUN echo 'Generated for docker compose'";
var container = builder.AddContainer("testcontainer", "testimage")
.WithDockerfileFactory(".", context => dockerfileContent);
var app = builder.Build();
app.Run();
// Verify Dockerfile was written to resource-specific path
var dockerfilePath = Path.Combine(workspace.Path, "testcontainer.Dockerfile");
Assert.True(File.Exists(dockerfilePath), $"Dockerfile should exist at {dockerfilePath}");
var actualContent = await File.ReadAllTextAsync(dockerfilePath);
await Verify(actualContent);
}
[Fact]
public void PublishAsync_InRunMode_DoesNotCreateDashboard()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run, workspace.Path);
builder.Services.AddSingleton<IResourceContainerImageManager, MockImageBuilder>();
builder.AddDockerComposeEnvironment("docker-compose")
.WithDashboard(); // Should be ignored in run mode
builder.AddContainer("api", "my-api")
.WithOtlpExporter();
var app = builder.Build();
// In run mode, no compose file should be generated
var composePath = Path.Combine(workspace.Path, "docker-compose.yaml");
Assert.False(File.Exists(composePath));
}
[Fact]
public async Task PrepareStep_GeneratesCorrectEnvFileWithDefaultEnvironmentName()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, workspace.Path, step: "prepare-docker-compose");
builder.Services.AddSingleton<IResourceContainerImageManager, MockImageBuilder>();
builder.Configuration["ConnectionStrings:cstest"] = "Server=localhost;Database=test";
var environment = builder.AddDockerComposeEnvironment("docker-compose");
var param1 = builder.AddParameter("param1", "defaultValue1");
var param2 = builder.AddParameter("param2", "defaultSecretValue", secret: true);
var cs = builder.AddConnectionString("cstest");
builder.AddContainer("testapp", "testimage")
.WithEnvironment("PARAM1", param1)
.WithEnvironment("PARAM2", param2)
.WithReference(cs);
var app = builder.Build();
app.Run();
var envFileContent = await File.ReadAllTextAsync(Path.Combine(workspace.Path, ".env.Production"));
await Verify(envFileContent, "env")
.UseParameters("default-environment");
}
[Fact]
public async Task PrepareStep_GeneratesCorrectEnvFileWithCustomEnvironmentName()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, workspace.Path, step: "prepare-docker-compose");
builder.Services.AddSingleton<IResourceContainerImageManager, MockImageBuilder>();
// Add a custom IHostEnvironment with a specific environment name
builder.Services.AddSingleton<Microsoft.Extensions.Hosting.IHostEnvironment>(new TestHostEnvironment("Staging"));
var environment = builder.AddDockerComposeEnvironment("docker-compose");
var param1 = builder.AddParameter("param1", "stagingValue");
var param2 = builder.AddParameter("param2", "defaultStagingSecret", secret: true);
builder.AddContainer("testapp", "testimage")
.WithEnvironment("PARAM1", param1)
.WithEnvironment("PARAM2", param2);
var app = builder.Build();
app.Run();
// Verify that the env file is created with the custom environment name
var envFilePath = Path.Combine(workspace.Path, ".env.Staging");
Assert.True(File.Exists(envFilePath), $"Expected env file at {envFilePath}");
var envFileContent = await File.ReadAllTextAsync(envFilePath);
await Verify(envFileContent, "env")
.UseParameters("custom-environment");
}
[Fact]
public async Task PrepareStep_GeneratesEnvFileWithVariousParameterTypes()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, workspace.Path, step: "prepare-docker-compose");
builder.Services.AddSingleton<IResourceContainerImageManager, MockImageBuilder>();
builder.Configuration["ConnectionStrings:dbConnection"] = "Server=localhost;Database=mydb";
var environment = builder.AddDockerComposeEnvironment("docker-compose");
// Various parameter types
var stringParam = builder.AddParameter("stringParam", "defaultString");
var secretParam = builder.AddParameter("secretParam", "defaultSecretParameter", secret: true);
var paramWithDefault = builder.AddParameter("paramWithDefault", "defaultValue", publishValueAsDefault: true);
var cs = builder.AddConnectionString("dbConnection");
builder.AddContainer("webapp", "webapp:latest")
.WithEnvironment("STRING_PARAM", stringParam)
.WithEnvironment("SECRET_PARAM", secretParam)
.WithEnvironment("PARAM_WITH_DEFAULT", paramWithDefault)
.WithReference(cs);
var app = builder.Build();
app.Run();
var envFileContent = await File.ReadAllTextAsync(Path.Combine(workspace.Path, ".env.Production"));
await Verify(envFileContent, "env")
.UseParameters("various-parameters");
}
[Fact]
public void PrepareStep_OverwritesExistingEnvFileAndLogsWarning()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, workspace.Path, step: "prepare-docker-compose");
builder.Services.AddSingleton<IResourceContainerImageManager, MockImageBuilder>();
builder.WithTestAndResourceLogging(outputHelper);
var environment = builder.AddDockerComposeEnvironment("docker-compose");
var param1 = builder.AddParameter("param1", "defaultValue1");
builder.AddContainer("testapp", "testimage")
.WithEnvironment("PARAM1", param1);
// Pre-create the env file to simulate it already existing
var envFilePath = Path.Combine(workspace.Path, ".env.Production");
File.WriteAllText(envFilePath, "# Old content\nOLD_KEY=old_value\n");
var app = builder.Build();
app.Run();
// Verify the file was overwritten with new content
var envFileContent = File.ReadAllText(envFilePath);
Assert.Contains("PARAM1", envFileContent);
Assert.DoesNotContain("OLD_KEY", envFileContent);
// The log message should be captured by the test output helper
// We can verify it was called by checking the test output
// The xunit logger will output to outputHelper
}
[Fact]
public void PrepareStep_OverwritesExistingEnvFileWithCustomEnvironmentName()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, workspace.Path, step: "prepare-docker-compose");
builder.Services.AddSingleton<IResourceContainerImageManager, MockImageBuilder>();
builder.Services.AddSingleton<Microsoft.Extensions.Hosting.IHostEnvironment>(new TestHostEnvironment("Staging"));
builder.WithTestAndResourceLogging(outputHelper);
var environment = builder.AddDockerComposeEnvironment("docker-compose");
var param1 = builder.AddParameter("param1", "stagingValue");
builder.AddContainer("testapp", "testimage")
.WithEnvironment("PARAM1", param1);
// Pre-create the env file with custom environment name
var envFilePath = Path.Combine(workspace.Path, ".env.Staging");
File.WriteAllText(envFilePath, "# Old staging content\nOLD_STAGING_KEY=old_staging_value\n");
var app = builder.Build();
app.Run();
// Verify the file was overwritten with new content
var envFileContent = File.ReadAllText(envFilePath);
Assert.Contains("PARAM1", envFileContent);
Assert.DoesNotContain("OLD_STAGING_KEY", envFileContent);
}
[Fact]
public async Task PrepareStep_ResolvesArbitraryIValueProviderSource()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, workspace.Path, step: "prepare-docker-compose");
builder.Services.AddSingleton<IResourceContainerImageManager, MockImageBuilder>();
builder.AddDockerComposeEnvironment("docker-compose");
builder.AddContainer("testapp", "testimage")
.WithEnvironment(context =>
{
context.EnvironmentVariables["MY_VAR"] = new TestConditionProvider("resolved-value");
});
var app = builder.Build();
app.Run();
// The compose file uses the user-specified container env var name; the .env file uses the
// name derived from the provider's ValueExpression. Docker Compose interpolates between them.
var composeContent = await File.ReadAllTextAsync(Path.Combine(workspace.Path, "docker-compose.yaml"));
var envFileContent = await File.ReadAllTextAsync(Path.Combine(workspace.Path, ".env.Production"));
await Verify(composeContent, "yaml")
.AppendContentAsFile(envFileContent, "env");
}
[Fact]
public async Task PrepareStep_SkipsParameterResolutionWhenStaticDefaultIsSet()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, workspace.Path, step: "prepare-docker-compose");
builder.Services.AddSingleton<IResourceContainerImageManager, MockImageBuilder>();
var environment = builder.AddDockerComposeEnvironment("docker-compose");
var param = builder.AddParameter("myparam", "dynamic-value");
builder.AddContainer("testapp", "testimage")
.WithEnvironment("MY_PARAM", param);
// Set a static default before PrepareAsync runs; parameter resolution should be skipped.
environment.ConfigureEnvFile(vars =>
{
if (vars.TryGetValue("MYPARAM", out var envVar))
{
envVar.DefaultValue = "static-override";
}
});
var app = builder.Build();
app.Run();
var envFileContent = await File.ReadAllTextAsync(Path.Combine(workspace.Path, ".env.Production"));
await Verify(envFileContent, "env");
}
[Fact]
public async Task PrepareStep_ResolvesContainerImageReferenceViaIValueProvider()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, workspace.Path, step: "prepare-docker-compose");
builder.Services.AddSingleton<IResourceContainerImageManager, MockImageBuilder>();
builder.Services.AddSingleton<IContainerRuntime, FakeContainerRuntime>();
builder.Services.AddSingleton<IContainerRuntimeResolver>(sp => (IContainerRuntimeResolver)sp.GetRequiredService<IContainerRuntime>());
builder.AddDockerComposeEnvironment("docker-compose");
// ProjectResource triggers AsContainerImagePlaceholder, which creates a CapturedEnvironmentVariable
// with Source=ContainerImageReference and DefaultValue="project1:latest".
// PrepareAsync should call GetValueAsync() via the IValueProvider branch; with no registry
// configured in the test, GetValueAsync() returns null and the entry is written empty —
// not "project1:latest". If the IValueProvider branch were skipped, the static default would appear.
builder.AddProject<TestProjectWithLaunchSettings>("project1");
var app = builder.Build();
app.Run();
var envFileContent = await File.ReadAllTextAsync(Path.Combine(workspace.Path, ".env.Production"));
await Verify(envFileContent, "env")
// The image tag includes the current time (e.g. "aspire-deploy-20260525182406"); scrub it so the snapshot is stable across runs.
.ScrubLinesWithReplace(line => Regex.Replace(line, @"aspire-deploy-\d{14}", "aspire-deploy-TIMESTAMP"));
}
[Fact]
public async Task PublishAsync_BindMounts_ReplacedWithEnvironmentPlaceholders()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, workspace.Path);
builder.Services.AddSingleton<IResourceContainerImageManager, MockImageBuilder>();
builder.AddDockerComposeEnvironment("docker-compose")
.WithDashboard(false);
// Add a container with bind mounts
builder.AddContainer("my-container", "my-image")
.WithBindMount("/host/path/data", "/container/data")
.WithBindMount("/another/host/path", "/container/config", isReadOnly: true);
var app = builder.Build();
app.Run();
var composePath = Path.Combine(workspace.Path, "docker-compose.yaml");
var envPath = Path.Combine(workspace.Path, ".env");
Assert.True(File.Exists(composePath));
Assert.True(File.Exists(envPath));
await Verify(File.ReadAllText(composePath), "yaml")
.AppendContentAsFile(File.ReadAllText(envPath), "env");
}
[Fact]
public async Task PublishAsync_DockerSocket_NotReplacedWithPlaceholder()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, workspace.Path);
builder.Services.AddSingleton<IResourceContainerImageManager, MockImageBuilder>();
builder.AddDockerComposeEnvironment("docker-compose")
.WithDashboard(false);
// Add a container with docker socket mount
builder.AddContainer("docker-proxy", "my-image")
.WithBindMount("/var/run/docker.sock", "/var/run/docker.sock");
var app = builder.Build();
app.Run();
var composePath = Path.Combine(workspace.Path, "docker-compose.yaml");
Assert.True(File.Exists(composePath));
var composeContent = File.ReadAllText(composePath);
// Docker socket should be preserved as-is, not replaced with placeholder
Assert.Contains("/var/run/docker.sock", composeContent);
await Verify(composeContent, "yaml");
}
[Fact]
public async Task PublishAsync_MixedBindMountsAndVolumes()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, workspace.Path);
builder.Services.AddSingleton<IResourceContainerImageManager, MockImageBuilder>();
builder.AddDockerComposeEnvironment("docker-compose")
.WithDashboard(false);
// Add a container with both bind mounts and volumes
builder.AddContainer("my-service", "my-image")
.WithVolume("my-volume", "/container/volume-data", env: "DATA_PATH")
.WithBindMount("/host/path/data", "/container/bind-data")
.WithBindMount("/var/run/docker.sock", "/var/run/docker.sock");
var app = builder.Build();
app.Run();
var composePath = Path.Combine(workspace.Path, "docker-compose.yaml");
var envPath = Path.Combine(workspace.Path, ".env");
Assert.True(File.Exists(composePath));
Assert.True(File.Exists(envPath));
await Verify(File.ReadAllText(composePath), "yaml")
.AppendContentAsFile(File.ReadAllText(envPath), "env");
}
[Fact]
public async Task PublishAsync_ProjectAndExecutableVolumesIncludeEnvironmentPaths()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, workspace.Path);
builder.Services.AddSingleton<IResourceContainerImageManager, MockImageBuilder>();
builder.AddDockerComposeEnvironment("docker-compose")
.WithDashboard(false);
builder.AddProject<TestProject>("project", launchProfileName: null)
.WithVolume("project-data", "/srv/project", env: "DATA_PATH");
builder.AddExecutable("executable", "node", ".")
.PublishAsDockerFile()
.WithVolume("executable-data", "/srv/executable", env: "DATA_PATH");
var app = builder.Build();
app.Run();
var composePath = Path.Combine(workspace.Path, "docker-compose.yaml");
Assert.True(File.Exists(composePath));
await Verify(File.ReadAllText(composePath), "yaml");
}
[Fact]
public async Task PublishAsync_ConfigureEnvFile_AllowsMutatingCapturedEnvVars()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, workspace.Path);
builder.Services.AddSingleton<IResourceContainerImageManager, MockImageBuilder>();
builder.AddDockerComposeEnvironment("docker-compose")
.WithDashboard(false)
.ConfigureEnvFile(envVars =>
{
// Modify the default value of a bind mount env var
foreach (var envVar in envVars.Values)
{
if (envVar.Source is ContainerMountAnnotation mount)
{
// Change the default path to a relative path
envVar.DefaultValue = "./" + Path.GetFileName(mount.Source);
}
}
});
// Add a container with bind mounts
builder.AddContainer("my-container", "my-image")
.WithBindMount("/host/path/data", "/container/data");
var app = builder.Build();
app.Run();
var composePath = Path.Combine(workspace.Path, "docker-compose.yaml");
var envPath = Path.Combine(workspace.Path, ".env");
Assert.True(File.Exists(composePath));
Assert.True(File.Exists(envPath));
await Verify(File.ReadAllText(composePath), "yaml")
.AppendContentAsFile(File.ReadAllText(envPath), "env");
}
[Fact]
public async Task PublishAsync_ConfigureEnvFile_CanRemoveGeneratedPlaceholder()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, workspace.Path);
builder.Services.AddSingleton<IResourceContainerImageManager, MockImageBuilder>();
// Create a parameter for the cert directory
var certDir = builder.AddParameter("certDir", secret: false);
builder.AddDockerComposeEnvironment("docker-compose")
.WithDashboard(false)
.ConfigureEnvFile(envVars =>
{
// Find and remove the auto-generated bind mount placeholder for yarp
var keysToRemove = envVars.Where(kv =>
kv.Value.Resource?.Name == "yarp" &&
kv.Value.Source is ContainerMountAnnotation).Select(kv => kv.Key).ToList();
foreach (var key in keysToRemove)
{
envVars.Remove(key);
}
});
// Add a container (yarp) with a bind mount
var yarp = builder.AddContainer("yarp", "my-image")
.WithBindMount("./certs", "/app/certs", isReadOnly: true);
// Override the volume source with a parameter placeholder
yarp.PublishAsDockerComposeService((svc, infra) =>
{
infra.Volumes[0].Source = certDir.AsEnvironmentPlaceholder(svc);
});
var app = builder.Build();
app.Run();
var composePath = Path.Combine(workspace.Path, "docker-compose.yaml");
var envPath = Path.Combine(workspace.Path, ".env");
Assert.True(File.Exists(composePath));
Assert.True(File.Exists(envPath));
await Verify(File.ReadAllText(composePath), "yaml")
.AppendContentAsFile(File.ReadAllText(envPath), "env");
}
[Fact]
public async Task PublishAsync_WindowsAbsoluteBindMountPath_ReplacedWithEnvironmentPlaceholders()
{
if (!OperatingSystem.IsWindows())
{
Assert.Skip("This test only runs on Windows");
return;
}
using var workspace = TemporaryWorkspace.Create(outputHelper);
var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, workspace.Path);
builder.Services.AddSingleton<IResourceContainerImageManager, MockImageBuilder>();
builder.AddDockerComposeEnvironment("docker-compose")
.WithDashboard(false);
// Add a container with Windows-specific absolute path bind mounts
builder.AddContainer("my-container", "my-image")
.WithBindMount(@"C:\Users\TestUser\Data", "/container/data")
.WithBindMount(@"D:\AppConfig", "/container/config", isReadOnly: true);
var app = builder.Build();
app.Run();
var composePath = Path.Combine(workspace.Path, "docker-compose.yaml");
var envPath = Path.Combine(workspace.Path, ".env");
Assert.True(File.Exists(composePath));
Assert.True(File.Exists(envPath));
await Verify(File.ReadAllText(composePath), "yaml")
.AppendContentAsFile(File.ReadAllText(envPath), "env");
}
[Fact]
public async Task PublishAsync_HandlesConditionalReferenceExpression()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, workspace.Path);
builder.Services.AddSingleton<IResourceContainerImageManager, MockImageBuilder>();
builder.AddDockerComposeEnvironment("docker-compose");
var api = builder.AddContainer("myapp", "mcr.microsoft.com/dotnet/aspnet:8.0")
.WithEnvironment(context =>
{
// Simulate a conditional expression like TLS-enabled connection strings produce.
// The condition evaluates statically at publish time.
var conditional = ReferenceExpression.CreateConditional(
new TestConditionProvider(bool.TrueString),
bool.TrueString,
ReferenceExpression.Create($",ssl=true"),
ReferenceExpression.Empty);
context.EnvironmentVariables["TLS_SUFFIX"] = conditional;
var conditionalFalse = ReferenceExpression.CreateConditional(
new TestConditionProvider(bool.FalseString),
bool.TrueString,
ReferenceExpression.Create($",ssl=true"),
ReferenceExpression.Create($",ssl=false"));
context.EnvironmentVariables["TLS_SUFFIX_FALSE"] = conditionalFalse;
});
var app = builder.Build();
app.Run();
var composePath = Path.Combine(workspace.Path, "docker-compose.yaml");
Assert.True(File.Exists(composePath));
await Verify(File.ReadAllText(composePath), "yaml");
}
[Fact]
public async Task PublishAsync_HandlesConditionalReferenceExpressionWithParameterCondition()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, workspace.Path);
builder.Services.AddSingleton<IResourceContainerImageManager, MockImageBuilder>();
builder.AddDockerComposeEnvironment("docker-compose");
// Use a real ParameterResource as the condition with a known default value.
var enableTls = builder.AddParameter("enable-tls", "True", publishValueAsDefault: true);
var api = builder.AddContainer("myapp", "mcr.microsoft.com/dotnet/aspnet:8.0")
.WithEnvironment(context =>
{
var conditional = ReferenceExpression.CreateConditional(
enableTls.Resource,
bool.TrueString,
ReferenceExpression.Create($",ssl=true"),
ReferenceExpression.Create($",ssl=false"));
context.EnvironmentVariables["TLS_SUFFIX"] = conditional;
});
var app = builder.Build();
app.Run();
var composePath = Path.Combine(workspace.Path, "docker-compose.yaml");
Assert.True(File.Exists(composePath));
await Verify(File.ReadAllText(composePath), "yaml");
}
[Fact]
public async Task PublishAsync_ResolvesEndpointTlsEnabledProperty()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, workspace.Path);
builder.Services.AddSingleton<IResourceContainerImageManager, MockImageBuilder>();
builder.AddDockerComposeEnvironment("docker-compose");
var cache = builder.AddContainer("cache", "redis")
.WithEndpoint(targetPort: 6379, name: "tcp", scheme: "tcp")
.WithEndpoint(targetPort: 6380, name: "tls", scheme: "tcp")
.WithEndpoint("tls", endpoint => endpoint.TlsEnabled = true);
builder.AddContainer("myapp", "mcr.microsoft.com/dotnet/aspnet:8.0")
.WithEnvironment("CACHE_TLS", cache.GetEndpoint("tls").Property(EndpointProperty.TlsEnabled))
.WithEnvironment("CACHE_PLAIN_TLS", cache.GetEndpoint("tcp").Property(EndpointProperty.TlsEnabled));
var app = builder.Build();
app.Run();
var composePath = Path.Combine(workspace.Path, "docker-compose.yaml");
Assert.True(File.Exists(composePath));
var composeContent = await File.ReadAllTextAsync(composePath);
Assert.Contains("CACHE_TLS: \"True\"", composeContent);
Assert.Contains("CACHE_PLAIN_TLS: \"False\"", composeContent);
}
private sealed class TestConditionProvider(string value) : IValueProvider, IManifestExpressionProvider
{
public string ValueExpression => "test-condition";
public ValueTask<string?> GetValueAsync(CancellationToken cancellationToken = default)
=> new(value);
public ValueTask<string?> GetValueAsync(ValueProviderContext context, CancellationToken cancellationToken = default)
=> new(value);
}
private sealed class MockImageBuilder : IResourceContainerImageManager
{
public bool BuildImageCalled { get; private set; }
public Task BuildImageAsync(IResource resource, CancellationToken cancellationToken = default)
{
BuildImageCalled = true;
return Task.CompletedTask;
}
public Task BuildImagesAsync(IEnumerable<IResource> resources, CancellationToken cancellationToken = default)
{
BuildImageCalled = true;
return Task.CompletedTask;
}
public Task PushImageAsync(IResource resource, CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
}
private sealed class TestProject : IProjectMetadata
{
public string ProjectPath => "another-path";
public LaunchSettings? LaunchSettings { get; set; }
}
private sealed class TestProjectWithLaunchSettings : IProjectMetadata
{
public Dictionary<string, LaunchProfile>? Profiles { get; set; } = [];
public string ProjectPath => "another-path";
public LaunchSettings? LaunchSettings => new() { Profiles = Profiles! };
public TestProjectWithLaunchSettings() => Profiles = new()
{
["https"] = new()
{
CommandName = "Project",
LaunchBrowser = true,
ApplicationUrl = "http://localhost:5031;https://localhost:5032",
EnvironmentVariables = new()
{
["ASPNETCORE_ENVIRONMENT"] = "Development"
}
},
["http"] = new()
{
CommandName = "Project",
LaunchBrowser = true,
ApplicationUrl = "http://localhost:5031",
EnvironmentVariables = new()
{
["ASPNETCORE_ENVIRONMENT"] = "Development"
}
}
};
}
private sealed class TestHostEnvironment(string environmentName) : Microsoft.Extensions.Hosting.IHostEnvironment
{
public string EnvironmentName { get; set; } = environmentName;
public string ApplicationName { get; set; } = "TestApplication";
public string ContentRootPath { get; set; } = "/test";
public Microsoft.Extensions.FileProviders.IFileProvider ContentRootFileProvider { get; set; } = null!;
}
}
#pragma warning restore CS0618