diff --git a/src/Aspire.Hosting.Docker/Aspire.Hosting.Docker.csproj b/src/Aspire.Hosting.Docker/Aspire.Hosting.Docker.csproj index c0db0e8d78a..80489508689 100644 --- a/src/Aspire.Hosting.Docker/Aspire.Hosting.Docker.csproj +++ b/src/Aspire.Hosting.Docker/Aspire.Hosting.Docker.csproj @@ -11,6 +11,9 @@ + + + diff --git a/src/Aspire.Hosting.Docker/DockerComposeEnvironmentResource.cs b/src/Aspire.Hosting.Docker/DockerComposeEnvironmentResource.cs index 9f92d22ecec..301bbc03abe 100644 --- a/src/Aspire.Hosting.Docker/DockerComposeEnvironmentResource.cs +++ b/src/Aspire.Hosting.Docker/DockerComposeEnvironmentResource.cs @@ -5,11 +5,13 @@ #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 Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.Dcp.Process; using Aspire.Hosting.Docker.Resources; using Aspire.Hosting.Pipelines; using Aspire.Hosting.Publishing; using Aspire.Hosting.Utils; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; namespace Aspire.Hosting.Docker; @@ -31,11 +33,6 @@ public class DockerComposeEnvironmentResource : Resource, IComputeEnvironmentRes /// public string? DefaultNetworkName { get; set; } - /// - /// Determines whether to build container images for the resources in this environment. - /// - public bool BuildContainerImages { get; set; } = true; - /// /// Determines whether to include an Aspire dashboard for telemetry visualization in this environment. /// @@ -53,20 +50,120 @@ public class DockerComposeEnvironmentResource : Resource, IComputeEnvironmentRes internal Dictionary ResourceMapping { get; } = new(new ResourceNameComparer()); + internal EnvFile? SharedEnvFile { get; set; } + internal PortAllocator PortAllocator { get; } = new(); /// The name of the Docker Compose environment. public DockerComposeEnvironmentResource(string name) : base(name) { - Annotations.Add(new PipelineStepAnnotation(context => + Annotations.Add(new PipelineStepAnnotation(async (factoryContext) => { - var step = new PipelineStep + var model = factoryContext.PipelineContext.Model; + var steps = new List(); + + var publishStep = new PipelineStep { Name = $"publish-{Name}", Action = ctx => PublishAsync(ctx) }; - step.RequiredBy(WellKnownPipelineSteps.Publish); - return step; + publishStep.RequiredBy(WellKnownPipelineSteps.Publish); + steps.Add(publishStep); + + // Expand deployment target steps for all compute resources + foreach (var computeResource in model.GetComputeResources()) + { + var deploymentTarget = computeResource.GetDeploymentTargetAnnotation(this)?.DeploymentTarget; + + if (deploymentTarget != null && deploymentTarget.TryGetAnnotationsOfType(out var annotations)) + { + // Resolve the deployment target's PipelineStepAnnotation and expand its steps + // We do this because the deployment target is not in the model + foreach (var annotation in annotations) + { + var childFactoryContext = new PipelineStepFactoryContext + { + PipelineContext = factoryContext.PipelineContext, + Resource = deploymentTarget + }; + + var deploymentTargetSteps = await annotation.CreateStepsAsync(childFactoryContext).ConfigureAwait(false); + + foreach (var step in deploymentTargetSteps) + { + // Ensure the step is associated with the deployment target resource + step.Resource ??= deploymentTarget; + } + + steps.AddRange(deploymentTargetSteps); + } + } + } + + var prepareStep = new PipelineStep + { + Name = $"prepare-{Name}", + Action = ctx => PrepareAsync(ctx) + }; + prepareStep.DependsOn(WellKnownPipelineSteps.Publish); + prepareStep.DependsOn(WellKnownPipelineSteps.Build); + steps.Add(prepareStep); + + var dockerComposeUpStep = new PipelineStep + { + Name = $"docker-compose-up-{Name}", + Action = ctx => DockerComposeUpAsync(ctx), + Tags = ["docker-compose-up"], + DependsOnSteps = [$"prepare-{Name}"] + }; + dockerComposeUpStep.RequiredBy(WellKnownPipelineSteps.Deploy); + steps.Add(dockerComposeUpStep); + + var dockerComposeDownStep = new PipelineStep + { + Name = $"docker-compose-down-{Name}", + Action = ctx => DockerComposeDownAsync(ctx), + Tags = ["docker-compose-down"] + }; + steps.Add(dockerComposeDownStep); + + return steps; + })); + + // Add pipeline configuration annotation to wire up dependencies + // This is where we wire up the build steps created by the resources + Annotations.Add(new PipelineConfigurationAnnotation(context => + { + // Wire up build step dependencies + // Build steps are created by ProjectResource and ContainerResource + foreach (var computeResource in context.Model.GetComputeResources()) + { + var deploymentTarget = computeResource.GetDeploymentTargetAnnotation(this)?.DeploymentTarget; + + if (deploymentTarget is null) + { + continue; + } + + // Execute the PipelineConfigurationAnnotation callbacks on the deployment target + if (deploymentTarget.TryGetAnnotationsOfType(out var annotations)) + { + foreach (var annotation in annotations) + { + annotation.Callback(context); + } + } + } + + // This ensures that resources that have to be built before deployments are handled + foreach (var computeResource in context.Model.GetBuildResources()) + { + var buildSteps = context.GetSteps(computeResource, WellKnownPipelineTags.BuildCompute); + + buildSteps.RequiredBy(WellKnownPipelineSteps.Deploy) + .RequiredBy($"docker-compose-up-{Name}") + .DependsOn(WellKnownPipelineSteps.DeployPrereq); + } })); } @@ -100,10 +197,174 @@ private Task PublishAsync(PipelineStepContext context) return dockerComposePublishingContext.WriteModelAsync(context.Model, this); } + private async Task DockerComposeUpAsync(PipelineStepContext context) + { + var outputPath = PublishingContextUtils.GetEnvironmentOutputPath(context, this); + var dockerComposeFilePath = Path.Combine(outputPath, "docker-compose.yaml"); + var envFilePath = GetEnvFilePath(context); + + if (!File.Exists(dockerComposeFilePath)) + { + throw new InvalidOperationException($"Docker Compose file not found at {dockerComposeFilePath}"); + } + + var deployTask = await context.ReportingStep.CreateTaskAsync($"Running docker compose up for **{Name}**", context.CancellationToken).ConfigureAwait(false); + await using (deployTask.ConfigureAwait(false)) + { + try + { + var arguments = $"compose -f \"{dockerComposeFilePath}\""; + + if (File.Exists(envFilePath)) + { + arguments += $" --env-file \"{envFilePath}\""; + } + + arguments += " up -d --remove-orphans"; + + var spec = new ProcessSpec("docker") + { + Arguments = arguments, + WorkingDirectory = outputPath, + ThrowOnNonZeroReturnCode = false, + InheritEnv = true, + OnOutputData = output => + { + context.Logger.LogDebug("docker compose up (stdout): {Output}", output); + }, + OnErrorData = error => + { + context.Logger.LogDebug("docker compose up (stderr): {Error}", error); + }, + }; + + var (pendingProcessResult, processDisposable) = ProcessUtil.Run(spec); + + await using (processDisposable) + { + var processResult = await pendingProcessResult + .WaitAsync(context.CancellationToken) + .ConfigureAwait(false); + + if (processResult.ExitCode != 0) + { + await deployTask.FailAsync($"docker compose up failed with exit code {processResult.ExitCode}", cancellationToken: context.CancellationToken).ConfigureAwait(false); + } + else + { + await deployTask.CompleteAsync($"Service **{Name}** is now running with Docker Compose locally", CompletionState.Completed, context.CancellationToken).ConfigureAwait(false); + } + } + } + catch (Exception ex) + { + await deployTask.CompleteAsync($"Docker Compose deployment failed: {ex.Message}", CompletionState.CompletedWithError, context.CancellationToken).ConfigureAwait(false); + throw; + } + } + } + + private async Task DockerComposeDownAsync(PipelineStepContext context) + { + var outputPath = PublishingContextUtils.GetEnvironmentOutputPath(context, this); + var dockerComposeFilePath = Path.Combine(outputPath, "docker-compose.yaml"); + var envFilePath = GetEnvFilePath(context); + + if (!File.Exists(dockerComposeFilePath)) + { + throw new InvalidOperationException($"Docker Compose file not found at {dockerComposeFilePath}"); + } + + var deployTask = await context.ReportingStep.CreateTaskAsync($"Running docker compose down for **{Name}**", context.CancellationToken).ConfigureAwait(false); + await using (deployTask.ConfigureAwait(false)) + { + try + { + var arguments = $"compose -f \"{dockerComposeFilePath}\""; + + if (File.Exists(envFilePath)) + { + arguments += $" --env-file \"{envFilePath}\""; + } + + arguments += " down"; + + var spec = new ProcessSpec("docker") + { + Arguments = arguments, + WorkingDirectory = outputPath, + ThrowOnNonZeroReturnCode = false, + InheritEnv = true + }; + + var (pendingProcessResult, processDisposable) = ProcessUtil.Run(spec); + + await using (processDisposable) + { + var processResult = await pendingProcessResult + .WaitAsync(context.CancellationToken) + .ConfigureAwait(false); + + if (processResult.ExitCode != 0) + { + await deployTask.FailAsync($"docker compose down failed with exit code {processResult.ExitCode}", cancellationToken: context.CancellationToken).ConfigureAwait(false); + } + else + { + await deployTask.CompleteAsync($"Docker Compose shutdown complete for **{Name}**", CompletionState.Completed, context.CancellationToken).ConfigureAwait(false); + } + } + } + catch (Exception ex) + { + await deployTask.CompleteAsync($"Docker Compose shutdown failed: {ex.Message}", CompletionState.CompletedWithError, context.CancellationToken).ConfigureAwait(false); + throw; + } + } + } + + private async Task PrepareAsync(PipelineStepContext context) + { + var envFilePath = GetEnvFilePath(context); + + if (CapturedEnvironmentVariables.Count == 0 || SharedEnvFile is null) + { + return; + } + + foreach (var entry in CapturedEnvironmentVariables) + { + var (key, (description, defaultValue, source)) = entry; + + if (defaultValue is null && source is ParameterResource parameter) + { + defaultValue = await parameter.GetValueAsync(context.CancellationToken).ConfigureAwait(false); + } + + if (source is ContainerImageReference cir && cir.Resource.TryGetContainerImageName(out var imageName)) + { + defaultValue = imageName; + } + + SharedEnvFile.Add(key, defaultValue, description, onlyIfMissing: false); + } + + SharedEnvFile.Save(envFilePath, includeValues: true); + } + internal string AddEnvironmentVariable(string name, string? description = null, string? defaultValue = null, object? source = null) { CapturedEnvironmentVariables[name] = (description, defaultValue, source); return $"${{{name}}}"; } + + private string GetEnvFilePath(PipelineStepContext context) + { + var outputPath = PublishingContextUtils.GetEnvironmentOutputPath(context, this); + var hostEnvironment = context.Services.GetService(); + var environmentName = hostEnvironment?.EnvironmentName ?? Name; + var envFilePath = Path.Combine(outputPath, $".env.{environmentName}"); + return envFilePath; + } } diff --git a/src/Aspire.Hosting.Docker/DockerComposePublisherLoggerExtensions.cs b/src/Aspire.Hosting.Docker/DockerComposePublisherLoggerExtensions.cs index d2d4390859b..fbd541c0a30 100644 --- a/src/Aspire.Hosting.Docker/DockerComposePublisherLoggerExtensions.cs +++ b/src/Aspire.Hosting.Docker/DockerComposePublisherLoggerExtensions.cs @@ -25,7 +25,7 @@ internal static partial class DockerComposePublisherLoggerExtensions [LoggerMessage(LogLevel.Information, "No resources found in the model.")] internal static partial void EmptyModel(this ILogger logger); - [LoggerMessage(LogLevel.Information, "Successfully generated Compose output in '{OutputPath}'")] + [LoggerMessage(LogLevel.Debug, "Successfully generated Compose output in '{OutputPath}'")] internal static partial void FinishGeneratingDockerCompose(this ILogger logger, string outputPath); [LoggerMessage(LogLevel.Warning, "Failed to get container image for resource '{ResourceName}', it will be skipped in the output.")] diff --git a/src/Aspire.Hosting.Docker/DockerComposePublishingContext.cs b/src/Aspire.Hosting.Docker/DockerComposePublishingContext.cs index 58701582ee0..cc672a89f8a 100644 --- a/src/Aspire.Hosting.Docker/DockerComposePublishingContext.cs +++ b/src/Aspire.Hosting.Docker/DockerComposePublishingContext.cs @@ -78,17 +78,10 @@ private async Task WriteDockerComposeOutputAsync(DistributedApplicationModel mod ? [r, .. model.Resources] : model.Resources; - var containerImagesToBuild = new List(); - foreach (var resource in resources) { if (resource.GetDeploymentTargetAnnotation(environment)?.DeploymentTarget is DockerComposeServiceResource serviceResource) { - if (environment.BuildContainerImages) - { - containerImagesToBuild.Add(serviceResource.TargetResource); - } - // Materialize Dockerfile factories for resources with DockerfileBuildAnnotation if (serviceResource.TargetResource.TryGetLastAnnotation(out var dockerfileBuildAnnotation) && dockerfileBuildAnnotation.DockerfileFactory is not null) @@ -143,12 +136,6 @@ private async Task WriteDockerComposeOutputAsync(DistributedApplicationModel mod } } - // Build container images for the services that require it - if (containerImagesToBuild.Count > 0) - { - await ImageBuilder.BuildImagesAsync(containerImagesToBuild, options: null, cancellationToken).ConfigureAwait(false); - } - var writeTask = await reportingStep.CreateTaskAsync( "Writing the Docker Compose file to the output path.", cancellationToken: cancellationToken).ConfigureAwait(false); @@ -165,33 +152,19 @@ private async Task WriteDockerComposeOutputAsync(DistributedApplicationModel mod if (environment.CapturedEnvironmentVariables.Count > 0) { - // Write a .env file with the environment variable names - // that are used in the compose file var envFilePath = Path.Combine(OutputPath, ".env"); - var envFile = EnvFile.Load(envFilePath); + var envFile = environment.SharedEnvFile ?? EnvFile.Load(envFilePath); foreach (var entry in environment.CapturedEnvironmentVariables ?? []) { - var (key, (description, defaultValue, source)) = entry; - var onlyIfMissing = true; + var (key, (description, _, _)) = entry; - // If the source is a parameter and there's no explicit default value, - // resolve the parameter's default value asynchronously - if (defaultValue is null && source is ParameterResource parameter && !parameter.Secret && parameter.Default is not null) - { - defaultValue = await parameter.GetValueAsync(cancellationToken).ConfigureAwait(false); - } - - if (source is ContainerImageReference cir && cir.Resource.TryGetContainerImageName(out var imageName)) - { - defaultValue = imageName; - onlyIfMissing = false; // Always update the image name if it changes - } - - envFile.Add(key, defaultValue, description, onlyIfMissing); + envFile.Add(key, value: null, description, onlyIfMissing: true); } - envFile.Save(envFilePath); + environment.SharedEnvFile = envFile; + + envFile.Save(envFilePath, includeValues: false); } await writeTask.SucceedAsync( diff --git a/src/Aspire.Hosting.Docker/EnvFile.cs b/src/Aspire.Hosting.Docker/EnvFile.cs index 5ac74226213..23dcde95098 100644 --- a/src/Aspire.Hosting.Docker/EnvFile.cs +++ b/src/Aspire.Hosting.Docker/EnvFile.cs @@ -3,10 +3,11 @@ namespace Aspire.Hosting.Docker; +internal sealed record EnvEntry(string Key, string? Value, string? Comment); + internal sealed class EnvFile { - private readonly List _lines = []; - private readonly HashSet _keys = []; + private readonly SortedDictionary _entries = []; public static EnvFile Load(string path) { @@ -16,12 +17,25 @@ public static EnvFile Load(string path) return envFile; } + string? currentComment = null; + foreach (var line in File.ReadAllLines(path)) { - envFile._lines.Add(line); - if (TryParseKey(line, out var key)) + var trimmed = line.TrimStart(); + if (trimmed.StartsWith('#')) { - envFile._keys.Add(key); + // Extract comment text (remove # and trim) + currentComment = trimmed.Length > 1 ? trimmed[1..].Trim() : string.Empty; + } + else if (TryParseKeyValue(line, out var key, out var value)) + { + envFile._entries[key] = new EnvEntry(key, value, currentComment); + currentComment = null; // Reset comment after associating it with a key + } + else + { + // Reset comment if we encounter a non-comment, non-key line + currentComment = null; } } return envFile; @@ -29,36 +43,18 @@ public static EnvFile Load(string path) public void Add(string key, string? value, string? comment, bool onlyIfMissing = true) { - if (_keys.Contains(key)) + if (_entries.ContainsKey(key) && onlyIfMissing) { - if (onlyIfMissing) - { - return; - } - - // Update the existing key's value. - for (int i = 0; i < _lines.Count; i++) - { - if (TryParseKey(_lines[i], out var lineKey) && lineKey == key) - { - _lines[i] = value is not null ? $"{key}={value}" : $"{key}="; - return; - } - } + return; } - if (!string.IsNullOrWhiteSpace(comment)) - { - _lines.Add($"# {comment}"); - } - _lines.Add(value is not null ? $"{key}={value}" : $"{key}="); - _lines.Add(string.Empty); - _keys.Add(key); + _entries[key] = new EnvEntry(key, value, comment); } - private static bool TryParseKey(string line, out string key) + private static bool TryParseKeyValue(string line, out string key, out string? value) { key = string.Empty; + value = null; var trimmed = line.TrimStart(); if (!trimmed.StartsWith('#') && trimmed.Contains('=')) { @@ -66,6 +62,7 @@ private static bool TryParseKey(string line, out string key) if (eqIndex > 0) { key = trimmed[..eqIndex].Trim(); + value = eqIndex < trimmed.Length - 1 ? trimmed[(eqIndex + 1)..] : string.Empty; return true; } } @@ -74,6 +71,47 @@ private static bool TryParseKey(string line, out string key) public void Save(string path) { - File.WriteAllLines(path, _lines); + var lines = new List(); + + foreach (var entry in _entries.Values) + { + if (!string.IsNullOrWhiteSpace(entry.Comment)) + { + lines.Add($"# {entry.Comment}"); + } + lines.Add(entry.Value is not null ? $"{entry.Key}={entry.Value}" : $"{entry.Key}="); + lines.Add(string.Empty); + } + + File.WriteAllLines(path, lines); + } + + public void Save(string path, bool includeValues) + { + if (includeValues) + { + Save(path); + } + else + { + SaveKeysOnly(path); + } + } + + private void SaveKeysOnly(string path) + { + var lines = new List(); + + foreach (var entry in _entries.Values) + { + if (!string.IsNullOrWhiteSpace(entry.Comment)) + { + lines.Add($"# {entry.Comment}"); + } + lines.Add($"{entry.Key}="); + lines.Add(string.Empty); + } + + File.WriteAllLines(path, lines); } } diff --git a/src/Aspire.Hosting/CompatibilitySuppressions.xml b/src/Aspire.Hosting/CompatibilitySuppressions.xml index 61c71755871..09661f93d68 100644 --- a/src/Aspire.Hosting/CompatibilitySuppressions.xml +++ b/src/Aspire.Hosting/CompatibilitySuppressions.xml @@ -197,4 +197,46 @@ lib/net8.0/Aspire.Hosting.dll true - \ No newline at end of file + + CP0011 + F:Aspire.Hosting.Publishing.ContainerTargetPlatform.Linux386 + lib/net8.0/Aspire.Hosting.dll + lib/net8.0/Aspire.Hosting.dll + true + + + CP0011 + F:Aspire.Hosting.Publishing.ContainerTargetPlatform.LinuxAmd64 + lib/net8.0/Aspire.Hosting.dll + lib/net8.0/Aspire.Hosting.dll + true + + + CP0011 + F:Aspire.Hosting.Publishing.ContainerTargetPlatform.LinuxArm + lib/net8.0/Aspire.Hosting.dll + lib/net8.0/Aspire.Hosting.dll + true + + + CP0011 + F:Aspire.Hosting.Publishing.ContainerTargetPlatform.LinuxArm64 + lib/net8.0/Aspire.Hosting.dll + lib/net8.0/Aspire.Hosting.dll + true + + + CP0011 + F:Aspire.Hosting.Publishing.ContainerTargetPlatform.WindowsAmd64 + lib/net8.0/Aspire.Hosting.dll + lib/net8.0/Aspire.Hosting.dll + true + + + CP0011 + F:Aspire.Hosting.Publishing.ContainerTargetPlatform.WindowsArm64 + lib/net8.0/Aspire.Hosting.dll + lib/net8.0/Aspire.Hosting.dll + true + + diff --git a/src/Aspire.Hosting/Publishing/ResourceContainerImageBuilder.cs b/src/Aspire.Hosting/Publishing/ResourceContainerImageBuilder.cs index c1c43449670..1d07e496b7a 100644 --- a/src/Aspire.Hosting/Publishing/ResourceContainerImageBuilder.cs +++ b/src/Aspire.Hosting/Publishing/ResourceContainerImageBuilder.cs @@ -33,37 +33,43 @@ public enum ContainerImageFormat /// Specifies the target platform for container images. /// [Experimental("ASPIREPIPELINES003", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] +[Flags] public enum ContainerTargetPlatform { /// /// Linux AMD64 (linux/amd64). /// - LinuxAmd64, + LinuxAmd64 = 1, /// /// Linux ARM64 (linux/arm64). /// - LinuxArm64, + LinuxArm64 = 2, /// /// Linux ARM (linux/arm). /// - LinuxArm, + LinuxArm = 4, /// /// Linux 386 (linux/386). /// - Linux386, + Linux386 = 8, /// /// Windows AMD64 (windows/amd64). /// - WindowsAmd64, + WindowsAmd64 = 16, /// /// Windows ARM64 (windows/arm64). /// - WindowsArm64 + WindowsArm64 = 32, + + /// + /// All Linux platforms (AMD64 and ARM64). + /// + AllLinux = LinuxAmd64 | LinuxArm64 } /// @@ -266,7 +272,20 @@ private async Task ExecuteDotnetPublishAsync(IResource resource, Container if (options.TargetPlatform is not null) { - arguments += $" /p:ContainerRuntimeIdentifier=\"{options.TargetPlatform.Value.ToMSBuildRuntimeIdentifierString()}\""; + // Use the appropriate MSBuild property based on the number of RIDs + var runtimeIds = options.TargetPlatform.Value.ToMSBuildRuntimeIdentifierString(); + var ridArray = runtimeIds.Split(';'); + + if (ridArray.Length == 1) + { + // Single platform - use ContainerRuntimeIdentifier + arguments += $" /p:ContainerRuntimeIdentifier=\"{ridArray[0]}\""; + } + else + { + // Multiple platforms - use RuntimeIdentifiers + arguments += $" /p:RuntimeIdentifiers=\"{runtimeIds}\""; + } } } @@ -434,30 +453,82 @@ internal static class ContainerTargetPlatformExtensions /// /// The target platform. /// The platform string in the format used by container runtimes. - public static string ToRuntimePlatformString(this ContainerTargetPlatform platform) => platform switch + public static string ToRuntimePlatformString(this ContainerTargetPlatform platform) { - ContainerTargetPlatform.LinuxAmd64 => "linux/amd64", - ContainerTargetPlatform.LinuxArm64 => "linux/arm64", - ContainerTargetPlatform.LinuxArm => "linux/arm", - ContainerTargetPlatform.Linux386 => "linux/386", - ContainerTargetPlatform.WindowsAmd64 => "windows/amd64", - ContainerTargetPlatform.WindowsArm64 => "windows/arm64", - _ => throw new ArgumentOutOfRangeException(nameof(platform), platform, "Unknown container target platform") - }; + var platforms = new List(); + + if (platform.HasFlag(ContainerTargetPlatform.LinuxAmd64)) + { + platforms.Add("linux/amd64"); + } + if (platform.HasFlag(ContainerTargetPlatform.LinuxArm64)) + { + platforms.Add("linux/arm64"); + } + if (platform.HasFlag(ContainerTargetPlatform.LinuxArm)) + { + platforms.Add("linux/arm"); + } + if (platform.HasFlag(ContainerTargetPlatform.Linux386)) + { + platforms.Add("linux/386"); + } + if (platform.HasFlag(ContainerTargetPlatform.WindowsAmd64)) + { + platforms.Add("windows/amd64"); + } + if (platform.HasFlag(ContainerTargetPlatform.WindowsArm64)) + { + platforms.Add("windows/arm64"); + } + + if (platforms.Count == 0) + { + throw new ArgumentOutOfRangeException(nameof(platform), platform, "Unknown container target platform"); + } + + return string.Join(",", platforms); + } /// - /// Converts the target platform to the format used by MSBuild ContainerRuntimeIdentifier. + /// Converts the target platform to the format used by MSBuild RuntimeIdentifiers. /// /// The target platform. /// The platform string in the format used by MSBuild. - public static string ToMSBuildRuntimeIdentifierString(this ContainerTargetPlatform platform) => platform switch + public static string ToMSBuildRuntimeIdentifierString(this ContainerTargetPlatform platform) { - ContainerTargetPlatform.LinuxAmd64 => "linux-x64", - ContainerTargetPlatform.LinuxArm64 => "linux-arm64", - ContainerTargetPlatform.LinuxArm => "linux-arm", - ContainerTargetPlatform.Linux386 => "linux-x86", - ContainerTargetPlatform.WindowsAmd64 => "win-x64", - ContainerTargetPlatform.WindowsArm64 => "win-arm64", - _ => throw new ArgumentOutOfRangeException(nameof(platform), platform, "Unknown container target platform") - }; + var rids = new List(); + + if (platform.HasFlag(ContainerTargetPlatform.LinuxAmd64)) + { + rids.Add("linux-x64"); + } + if (platform.HasFlag(ContainerTargetPlatform.LinuxArm64)) + { + rids.Add("linux-arm64"); + } + if (platform.HasFlag(ContainerTargetPlatform.LinuxArm)) + { + rids.Add("linux-arm"); + } + if (platform.HasFlag(ContainerTargetPlatform.Linux386)) + { + rids.Add("linux-x86"); + } + if (platform.HasFlag(ContainerTargetPlatform.WindowsAmd64)) + { + rids.Add("win-x64"); + } + if (platform.HasFlag(ContainerTargetPlatform.WindowsArm64)) + { + rids.Add("win-arm64"); + } + + if (rids.Count == 0) + { + throw new ArgumentOutOfRangeException(nameof(platform), platform, "Unknown container target platform"); + } + + return string.Join(";", rids); + } } diff --git a/tests/Aspire.Hosting.Docker.Tests/DockerComposePublisherTests.cs b/tests/Aspire.Hosting.Docker.Tests/DockerComposePublisherTests.cs index 507fdcb3b4d..a834f8425c6 100644 --- a/tests/Aspire.Hosting.Docker.Tests/DockerComposePublisherTests.cs +++ b/tests/Aspire.Hosting.Docker.Tests/DockerComposePublisherTests.cs @@ -161,21 +161,16 @@ public async Task DockerComposeCorrectlyEmitsPortMappings() await Verify(File.ReadAllText(composePath), "yaml"); } - [Theory] - [InlineData(true)] - [InlineData(false)] - public void DockerComposeHandleImageBuilding(bool shouldBuildImages) + [Fact] + public void DockerComposeDoesNotHandleImageBuildingDuringPublish() { using var tempDir = new TempDirectory(); - using var builder = TestDistributedApplicationBuilder.Create(["--operation", "publish", "--publisher", "default", "--output-path", tempDir.Path]) + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, tempDir.Path, step: "publish-docker-compose") .WithTestAndResourceLogging(outputHelper); builder.Services.AddSingleton(); - builder.AddDockerComposeEnvironment("docker-compose") - .WithProperties(e => e.BuildContainerImages = shouldBuildImages); - - builder.Services.AddSingleton(); + builder.AddDockerComposeEnvironment("docker-compose"); builder.AddContainer("resource", "mcr.microsoft.com/dotnet/aspnet:8.0") .WithEnvironment("ASPNETCORE_ENVIRONMENT", "Development") @@ -187,12 +182,11 @@ public void DockerComposeHandleImageBuilding(bool shouldBuildImages) Assert.NotNull(mockImageBuilder); - // Act app.Run(); var composePath = Path.Combine(tempDir.Path, "docker-compose.yaml"); Assert.True(File.Exists(composePath)); - Assert.Equal(shouldBuildImages, mockImageBuilder.BuildImageCalled); + Assert.False(mockImageBuilder.BuildImageCalled); } [Fact] @@ -478,7 +472,7 @@ public async Task PublishAsync_WithDockerfileFactory_WritesDockerfileToOutputFol var dockerfilePath = Path.Combine(tempDir.Path, "testcontainer.Dockerfile"); Assert.True(File.Exists(dockerfilePath), $"Dockerfile should exist at {dockerfilePath}"); var actualContent = await File.ReadAllTextAsync(dockerfilePath); - + await Verify(actualContent); } @@ -503,6 +497,97 @@ public void PublishAsync_InRunMode_DoesNotCreateDashboard() Assert.False(File.Exists(composePath)); } + [Fact] + public async Task PrepareStep_GeneratesCorrectEnvFileWithDefaultEnvironmentName() + { + using var tempDir = new TempDirectory(); + + var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, tempDir.Path, step: "prepare-docker-compose"); + builder.Services.AddSingleton(); + 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(tempDir.Path, ".env.Production")); + await Verify(envFileContent, "env") + .UseParameters("default-environment"); + } + + [Fact] + public async Task PrepareStep_GeneratesCorrectEnvFileWithCustomEnvironmentName() + { + using var tempDir = new TempDirectory(); + + var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, tempDir.Path, step: "prepare-docker-compose"); + builder.Services.AddSingleton(); + + // Add a custom IHostEnvironment with a specific environment name + builder.Services.AddSingleton(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(tempDir.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 tempDir = new TempDirectory(); + + var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, tempDir.Path, step: "prepare-docker-compose"); + builder.Services.AddSingleton(); + 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(tempDir.Path, ".env.Production")); + await Verify(envFileContent, "env") + .UseParameters("various-parameters"); + } + private sealed class MockImageBuilder : IResourceContainerImageBuilder { public bool BuildImageCalled { get; private set; } @@ -567,4 +652,12 @@ private sealed class TestProjectWithLaunchSettings : IProjectMetadata } }; } + + 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!; + } } diff --git a/tests/Aspire.Hosting.Docker.Tests/Snapshots/DockerComposePublisherTests.DockerComposeAppendsNewKeysToEnvFileOnPublish#01.verified.env b/tests/Aspire.Hosting.Docker.Tests/Snapshots/DockerComposePublisherTests.DockerComposeAppendsNewKeysToEnvFileOnPublish#01.verified.env index c2fbff884af..39ea2dfd67f 100644 --- a/tests/Aspire.Hosting.Docker.Tests/Snapshots/DockerComposePublisherTests.DockerComposeAppendsNewKeysToEnvFileOnPublish#01.verified.env +++ b/tests/Aspire.Hosting.Docker.Tests/Snapshots/DockerComposePublisherTests.DockerComposeAppendsNewKeysToEnvFileOnPublish#01.verified.env @@ -1,5 +1,5 @@ # Parameter param1 -PARAM1=changed +PARAM1= # Parameter param2 PARAM2= diff --git a/tests/Aspire.Hosting.Docker.Tests/Snapshots/DockerComposePublisherTests.DockerComposeAppliesServiceCustomizations.verified.env b/tests/Aspire.Hosting.Docker.Tests/Snapshots/DockerComposePublisherTests.DockerComposeAppliesServiceCustomizations.verified.env index 63d7826c174..41da6076b3a 100644 --- a/tests/Aspire.Hosting.Docker.Tests/Snapshots/DockerComposePublisherTests.DockerComposeAppliesServiceCustomizations.verified.env +++ b/tests/Aspire.Hosting.Docker.Tests/Snapshots/DockerComposePublisherTests.DockerComposeAppliesServiceCustomizations.verified.env @@ -1,3 +1,3 @@ # Parameter param-1 -PARAM_1=default-name +PARAM_1= diff --git a/tests/Aspire.Hosting.Docker.Tests/Snapshots/DockerComposePublisherTests.DockerComposeDoesNotOverwriteEnvFileOnPublish#01.verified.env b/tests/Aspire.Hosting.Docker.Tests/Snapshots/DockerComposePublisherTests.DockerComposeDoesNotOverwriteEnvFileOnPublish#01.verified.env index c9a8d7c3a31..17417ffb17d 100644 --- a/tests/Aspire.Hosting.Docker.Tests/Snapshots/DockerComposePublisherTests.DockerComposeDoesNotOverwriteEnvFileOnPublish#01.verified.env +++ b/tests/Aspire.Hosting.Docker.Tests/Snapshots/DockerComposePublisherTests.DockerComposeDoesNotOverwriteEnvFileOnPublish#01.verified.env @@ -1,3 +1,3 @@ # Parameter param1 -PARAM1=changed +PARAM1= diff --git a/tests/Aspire.Hosting.Docker.Tests/Snapshots/DockerComposePublisherTests.DockerComposeWithProjectResources.verified.env b/tests/Aspire.Hosting.Docker.Tests/Snapshots/DockerComposePublisherTests.DockerComposeWithProjectResources.verified.env index 540c57089d1..68e14902f2c 100644 --- a/tests/Aspire.Hosting.Docker.Tests/Snapshots/DockerComposePublisherTests.DockerComposeWithProjectResources.verified.env +++ b/tests/Aspire.Hosting.Docker.Tests/Snapshots/DockerComposePublisherTests.DockerComposeWithProjectResources.verified.env @@ -1,6 +1,6 @@ -# Default container port for project1 -PROJECT1_PORT=8080 +# Container image name for project1 +PROJECT1_IMAGE= -# Container image name for project1 -PROJECT1_IMAGE=project1:latest +# Default container port for project1 +PROJECT1_PORT= diff --git a/tests/Aspire.Hosting.Docker.Tests/Snapshots/DockerComposePublisherTests.PrepareStep_GeneratesCorrectEnvFileWithCustomEnvironmentName.verified.env b/tests/Aspire.Hosting.Docker.Tests/Snapshots/DockerComposePublisherTests.PrepareStep_GeneratesCorrectEnvFileWithCustomEnvironmentName.verified.env new file mode 100644 index 00000000000..7edeb73bd84 --- /dev/null +++ b/tests/Aspire.Hosting.Docker.Tests/Snapshots/DockerComposePublisherTests.PrepareStep_GeneratesCorrectEnvFileWithCustomEnvironmentName.verified.env @@ -0,0 +1,6 @@ +# Parameter param1 +PARAM1=stagingValue + +# Parameter param2 +PARAM2=defaultStagingSecret + diff --git a/tests/Aspire.Hosting.Docker.Tests/Snapshots/DockerComposePublisherTests.PrepareStep_GeneratesCorrectEnvFileWithDefaultEnvironmentName.verified.env b/tests/Aspire.Hosting.Docker.Tests/Snapshots/DockerComposePublisherTests.PrepareStep_GeneratesCorrectEnvFileWithDefaultEnvironmentName.verified.env new file mode 100644 index 00000000000..187b1dab5f0 --- /dev/null +++ b/tests/Aspire.Hosting.Docker.Tests/Snapshots/DockerComposePublisherTests.PrepareStep_GeneratesCorrectEnvFileWithDefaultEnvironmentName.verified.env @@ -0,0 +1,9 @@ +# Parameter cstest +CSTEST=Server=localhost;Database=test + +# Parameter param1 +PARAM1=defaultValue1 + +# Parameter param2 +PARAM2=defaultSecretValue + diff --git a/tests/Aspire.Hosting.Docker.Tests/Snapshots/DockerComposePublisherTests.PrepareStep_GeneratesEnvFileWithVariousParameterTypes.verified.env b/tests/Aspire.Hosting.Docker.Tests/Snapshots/DockerComposePublisherTests.PrepareStep_GeneratesEnvFileWithVariousParameterTypes.verified.env new file mode 100644 index 00000000000..0b1db0c4a32 --- /dev/null +++ b/tests/Aspire.Hosting.Docker.Tests/Snapshots/DockerComposePublisherTests.PrepareStep_GeneratesEnvFileWithVariousParameterTypes.verified.env @@ -0,0 +1,12 @@ +# Parameter dbConnection +DBCONNECTION=Server=localhost;Database=mydb + +# Parameter paramWithDefault +PARAMWITHDEFAULT=defaultValue + +# Parameter secretParam +SECRETPARAM=defaultSecretParameter + +# Parameter stringParam +STRINGPARAM=defaultString + diff --git a/tests/Aspire.Hosting.Docker.Tests/Snapshots/DockerComposePublisherTests.PublishAsync_GeneratesValidDockerComposeFile.verified.env b/tests/Aspire.Hosting.Docker.Tests/Snapshots/DockerComposePublisherTests.PublishAsync_GeneratesValidDockerComposeFile.verified.env index 4fc5900e199..88ebb164fcf 100644 --- a/tests/Aspire.Hosting.Docker.Tests/Snapshots/DockerComposePublisherTests.PublishAsync_GeneratesValidDockerComposeFile.verified.env +++ b/tests/Aspire.Hosting.Docker.Tests/Snapshots/DockerComposePublisherTests.PublishAsync_GeneratesValidDockerComposeFile.verified.env @@ -5,8 +5,8 @@ PARAM0= PARAM1= # Parameter param2 -PARAM2=default +PARAM2= # Container image name for project1 -PROJECT1_IMAGE=project1:latest +PROJECT1_IMAGE=