Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 39 additions & 33 deletions src/Aspire.Hosting.Docker/DockerComposeEnvironmentResource.cs
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,11 @@ public DockerComposeEnvironmentResource(string name) : base(name)
annotation.Callback(context);
}
}

// Ensure print-summary steps from deployment targets run after docker-compose-up
var printSummarySteps = context.GetSteps(deploymentTarget, "print-summary");
var dockerComposeUpSteps = context.GetSteps(this, "docker-compose-up");
printSummarySteps.DependsOn(dockerComposeUpSteps);
}

// This ensures that resources that have to be built before deployments are handled
Expand Down Expand Up @@ -200,7 +205,6 @@ 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))
{
Expand All @@ -212,17 +216,10 @@ private async Task DockerComposeUpAsync(PipelineStepContext context)
{
try
{
var projectName = GetDockerComposeProjectName(context);
var arguments = $"compose -f \"{dockerComposeFilePath}\" --project-name \"{projectName}\"";

if (File.Exists(envFilePath))
{
arguments += $" --env-file \"{envFilePath}\"";
}

var arguments = GetDockerComposeArguments(context, this);
arguments += " up -d --remove-orphans";

context.Logger.LogDebug("Running docker compose up with project name: {ProjectName}, arguments: {Arguments}", projectName, arguments);
context.Logger.LogDebug("Running docker compose up with arguments: {Arguments}", arguments);

var spec = new ProcessSpec("docker")
{
Expand Down Expand Up @@ -270,7 +267,6 @@ 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))
{
Expand All @@ -282,17 +278,10 @@ private async Task DockerComposeDownAsync(PipelineStepContext context)
{
try
{
var projectName = GetDockerComposeProjectName(context);
var arguments = $"compose -f \"{dockerComposeFilePath}\" --project-name \"{projectName}\"";

if (File.Exists(envFilePath))
{
arguments += $" --env-file \"{envFilePath}\"";
}

var arguments = GetDockerComposeArguments(context, this);
arguments += " down";

context.Logger.LogDebug("Running docker compose down with project name: {ProjectName}, arguments: {Arguments}", projectName, arguments);
context.Logger.LogDebug("Running docker compose down with arguments: {Arguments}", arguments);

var spec = new ProcessSpec("docker")
{
Expand Down Expand Up @@ -330,7 +319,7 @@ private async Task DockerComposeDownAsync(PipelineStepContext context)

private async Task PrepareAsync(PipelineStepContext context)
{
var envFilePath = GetEnvFilePath(context);
var envFilePath = GetEnvFilePath(context, this);

if (CapturedEnvironmentVariables.Count == 0)
{
Expand Down Expand Up @@ -367,7 +356,33 @@ internal string AddEnvironmentVariable(string name, string? description = null,
return $"${{{name}}}";
}

private string GetDockerComposeProjectName(PipelineStepContext context)
internal static string GetEnvFilePath(PipelineStepContext context, DockerComposeEnvironmentResource environment)
{
var outputPath = PublishingContextUtils.GetEnvironmentOutputPath(context, environment);
var hostEnvironment = context.Services.GetService<Microsoft.Extensions.Hosting.IHostEnvironment>();
var environmentName = hostEnvironment?.EnvironmentName ?? environment.Name;
var envFilePath = Path.Combine(outputPath, $".env.{environmentName}");
return envFilePath;
}

internal static string GetDockerComposeArguments(PipelineStepContext context, DockerComposeEnvironmentResource environment)
{
var outputPath = PublishingContextUtils.GetEnvironmentOutputPath(context, environment);
var dockerComposeFilePath = Path.Combine(outputPath, "docker-compose.yaml");
var envFilePath = GetEnvFilePath(context, environment);
var projectName = GetDockerComposeProjectName(context, environment);

var arguments = $"compose -f \"{dockerComposeFilePath}\" --project-name \"{projectName}\"";

if (File.Exists(envFilePath))
{
arguments += $" --env-file \"{envFilePath}\"";
}

return arguments;
}

internal static string GetDockerComposeProjectName(PipelineStepContext context, DockerComposeEnvironmentResource environment)
{
// Get the AppHost:PathSha256 from configuration to disambiguate projects
var configuration = context.Services.GetService<IConfiguration>();
Expand All @@ -377,19 +392,10 @@ private string GetDockerComposeProjectName(PipelineStepContext context)
{
// Use first 8 characters of the hash for readability
// Format: aspire-{environmentName}-{sha8}
return $"aspire-{Name.ToLowerInvariant()}-{appHostSha[..8].ToLowerInvariant()}";
return $"aspire-{environment.Name.ToLowerInvariant()}-{appHostSha[..8].ToLowerInvariant()}";
}

// Fallback to just using the environment name if PathSha256 is not available
return $"aspire-{Name.ToLowerInvariant()}";
}

private string GetEnvFilePath(PipelineStepContext context)
{
var outputPath = PublishingContextUtils.GetEnvironmentOutputPath(context, this);
var hostEnvironment = context.Services.GetService<Microsoft.Extensions.Hosting.IHostEnvironment>();
var environmentName = hostEnvironment?.EnvironmentName ?? Name;
var envFilePath = Path.Combine(outputPath, $".env.{environmentName}");
return envFilePath;
return $"aspire-{environment.Name.ToLowerInvariant()}";
}
}
208 changes: 204 additions & 4 deletions src/Aspire.Hosting.Docker/DockerComposeServiceResource.cs
Original file line number Diff line number Diff line change
@@ -1,19 +1,59 @@
// 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 ASPIREPIPELINES001

using System.Globalization;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using Aspire.Hosting.ApplicationModel;
using Aspire.Hosting.Dcp.Process;
using Aspire.Hosting.Docker.Resources.ComposeNodes;
using Aspire.Hosting.Docker.Resources.ServiceNodes;
using Aspire.Hosting.Pipelines;
using Aspire.Hosting.Utils;
using Microsoft.Extensions.Logging;

namespace Aspire.Hosting.Docker;

/// <summary>
/// Represents a compute resource for Docker Compose with strongly-typed properties.
/// </summary>
public class DockerComposeServiceResource(string name, IResource resource, DockerComposeEnvironmentResource composeEnvironmentResource) : Resource(name), IResourceWithParent<DockerComposeEnvironmentResource>
public class DockerComposeServiceResource : Resource, IResourceWithParent<DockerComposeEnvironmentResource>
{
private readonly IResource _targetResource;
private readonly DockerComposeEnvironmentResource _composeEnvironmentResource;

/// <summary>
/// Initializes a new instance of the <see cref="DockerComposeServiceResource"/> class.
/// </summary>
/// <param name="name">The name of the resource.</param>
/// <param name="resource">The target resource.</param>
/// <param name="composeEnvironmentResource">The Docker Compose environment resource.</param>
public DockerComposeServiceResource(string name, IResource resource, DockerComposeEnvironmentResource composeEnvironmentResource) : base(name)
{
_targetResource = resource;
_composeEnvironmentResource = composeEnvironmentResource;

// Add pipeline step annotation to display endpoints after deployment
Annotations.Add(new PipelineStepAnnotation((factoryContext) =>
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
Annotations.Add(new PipelineStepAnnotation((factoryContext) =>
Annotations.Add(new PipelineStepAnnotation(_ =>

{
var steps = new List<PipelineStep>();

var printResourceSummary = new PipelineStep
{
Name = $"print-{resource.Name}-summary",
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
Name = $"print-{resource.Name}-summary",
Name = $"print-{_targetResource.Name}-summary",

Action = async ctx => await PrintEndpointsAsync(ctx, composeEnvironmentResource).ConfigureAwait(false),
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
Action = async ctx => await PrintEndpointsAsync(ctx, composeEnvironmentResource).ConfigureAwait(false),
Action = async ctx => await PrintEndpointsAsync(ctx, _composeEnvironmentResource).ConfigureAwait(false),

Tags = ["print-summary"],
RequiredBySteps = [WellKnownPipelineSteps.Deploy]
};

steps.Add(printResourceSummary);

return steps;
}));
}
Copy link

Copilot AI Nov 26, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing blank line before the XML documentation comment. According to the codebase formatting conventions, there should be a blank line between the closing brace of the constructor and the XML documentation comment for the next member.

Suggested change
}
}

Copilot uses AI. Check for mistakes.
/// <summary>
/// Most common shell executables used as container entrypoints in Linux containers.
/// These are used to identify when a container's entrypoint is a shell that will execute commands.
Expand Down Expand Up @@ -44,7 +84,7 @@ internal record struct EndpointMapping(
/// <summary>
/// Gets the resource that is the target of this Docker Compose service.
/// </summary>
internal IResource TargetResource => resource;
internal IResource TargetResource => _targetResource;

/// <summary>
/// Gets the collection of environment variables for the Docker Compose service.
Expand All @@ -67,13 +107,13 @@ internal record struct EndpointMapping(
internal Dictionary<string, EndpointMapping> EndpointMappings { get; } = [];

/// <inheritdoc/>
public DockerComposeEnvironmentResource Parent => composeEnvironmentResource;
public DockerComposeEnvironmentResource Parent => _composeEnvironmentResource;

internal Service BuildComposeService()
{
var composeService = new Service
{
Name = resource.Name.ToLowerInvariant(),
Name = TargetResource.Name.ToLowerInvariant(),
};

if (TryGetContainerImageName(TargetResource, out var containerImageName))
Expand Down Expand Up @@ -265,4 +305,164 @@ private void AddVolumes(Service composeService)
composeService.AddVolume(volume);
}
}

private async Task PrintEndpointsAsync(PipelineStepContext context, DockerComposeEnvironmentResource environment)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Doesn't this need to handle potential slow startup of services? What guaranteees that docker compose ps --format json will have the information by the time you call it?

{
Comment on lines +309 to +310
Copy link

Copilot AI Nov 26, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PrintEndpointsAsync method is missing XML documentation. According to the Aspire XML documentation guidelines, internal methods should have brief <summary> tags explaining what they do. Consider adding:

/// <summary>
/// Prints the endpoints for the Docker Compose service after deployment.
/// </summary>
Suggested change
private async Task PrintEndpointsAsync(PipelineStepContext context, DockerComposeEnvironmentResource environment)
{
/// <summary>
/// Prints the endpoints for the Docker Compose service after deployment.
/// </summary>
private async Task PrintEndpointsAsync(PipelineStepContext context, DockerComposeEnvironmentResource environment)

Copilot uses AI. Check for mistakes.
var outputPath = PublishingContextUtils.GetEnvironmentOutputPath(context, environment);
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remove this declaration and check for dockerComposeFilePath since we do it in DockerComposeEnvironmentResource.GetDockerComposeArguments now.

var dockerComposeFilePath = Path.Combine(outputPath, "docker-compose.yaml");

if (!File.Exists(dockerComposeFilePath))
{
context.Logger.LogWarning("Docker Compose file not found at {Path}", dockerComposeFilePath);
return;
}

try
{
// Use docker compose ps to get the running containers and their port mappings
var arguments = DockerComposeEnvironmentResource.GetDockerComposeArguments(context, environment);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot add a comment that shows an example of the expected format being parsed here from docker compose.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot add a comment that shows an example of the expected format being parsed here from docker compose.

arguments += " ps --format json";

var outputLines = new List<string>();

var spec = new ProcessSpec("docker")
{
Arguments = arguments,
WorkingDirectory = outputPath,
ThrowOnNonZeroReturnCode = false,
InheritEnv = true,
OnOutputData = output =>
{
if (!string.IsNullOrWhiteSpace(output))
{
outputLines.Add(output);
}
},
OnErrorData = error =>
{
if (!string.IsNullOrWhiteSpace(error))
{
context.Logger.LogDebug("docker compose ps (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)
{
context.Logger.LogWarning("Failed to query Docker Compose services for {ResourceName}. Exit code: {ExitCode}", TargetResource.Name, processResult.ExitCode);
return;
}

// Parse the JSON output to find port mappings for this service
var serviceName = TargetResource.Name.ToLowerInvariant();
var endpoints = new HashSet<string>(StringComparer.OrdinalIgnoreCase);

// Get all external endpoint mappings for this resource
var externalEndpointMappings = EndpointMappings.Values.Where(m => m.IsExternal).ToList();

// If there are no external endpoints configured, we're done
if (externalEndpointMappings.Count == 0)
{
context.ReportingStep.Log(LogLevel.Information, $"Successfully deployed **{TargetResource.Name}** to Docker Compose environment **{environment.Name}**. No public endpoints were configured.", enableMarkdown: true);
return;
}

foreach (var line in outputLines)
{
try
{
var serviceInfo = JsonSerializer.Deserialize(line, DockerComposeJsonContext.Default.DockerComposeServiceInfo);

if (serviceInfo is null ||
!string.Equals(serviceInfo.Service, serviceName, StringComparison.OrdinalIgnoreCase))
{
continue;
}

if (serviceInfo.Publishers is not { Count: > 0 })
{
continue;
}

foreach (var publisher in serviceInfo.Publishers)
{
// Skip ports that aren't actually published (port 0 or null means not exposed)
if (publisher.PublishedPort is not > 0)
{
continue;
}

// Try to find a matching external endpoint to get the scheme
// Match by internal port (numeric) or by exposed port
// InternalPort may be a placeholder like ${API_PORT} for projects, so also check ExposedPort
var targetPortStr = publisher.TargetPort?.ToString(CultureInfo.InvariantCulture);
var endpointMapping = externalEndpointMappings
.FirstOrDefault(m => m.InternalPort == targetPortStr || m.ExposedPort == publisher.TargetPort);

// If we found a matching endpoint, use its scheme; otherwise default to http for external ports
var scheme = endpointMapping.Scheme ?? "http";

// Only add if we found a matching external endpoint OR if scheme is http/https
// (published ports are external by definition in docker compose)
if (endpointMapping.IsExternal || scheme is "http" or "https")
{
var endpoint = $"{scheme}://localhost:{publisher.PublishedPort}";
endpoints.Add(endpoint);
}
}
}
Comment on lines +408 to +414
Copy link

Copilot AI Nov 26, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The logic for determining which endpoints to display could be clearer. The condition endpointMapping.IsExternal || scheme is "http" or "https" is confusing because:

  1. externalEndpointMappings on line 371 already filters to external endpoints
  2. When FirstOrDefault returns no match (line 410), endpointMapping will be the default struct value where IsExternal = false
  3. The fallback to checking scheme is "http" or "https" suggests the intent is to show all http/https ports even without explicit mapping

Consider adding a comment to clarify this logic, or restructuring to make the intent clearer:

// Show endpoint if: it matches an external endpoint mapping, OR it's an http/https port (published ports are external by default)
var hasExplicitMapping = endpointMapping.Resource is not null;
if (hasExplicitMapping || scheme is "http" or "https")
{
    var endpoint = $"{scheme}://localhost:{publisher.PublishedPort}";
    endpoints.Add(endpoint);
}

Copilot uses AI. Check for mistakes.
catch (JsonException ex)
{
context.Logger.LogDebug(ex, "Failed to parse docker compose ps output line: {Line}", line);
}
}

// Display the endpoints
if (endpoints.Count > 0)
{
var endpointList = string.Join(", ", endpoints.Select(e => $"[{e}]({e})"));
context.ReportingStep.Log(LogLevel.Information, $"Successfully deployed **{TargetResource.Name}** to {endpointList}", enableMarkdown: true);
}
else
{
context.ReportingStep.Log(LogLevel.Information, $"Successfully deployed **{TargetResource.Name}** to Docker Compose environment **{environment.Name}**. No public endpoints were configured.", enableMarkdown: true);
}
}
}
catch (Exception ex)
{
context.Logger.LogWarning(ex, "Failed to retrieve endpoints for {ResourceName}", TargetResource.Name);
}
}

/// <summary>
Comment on lines 309 to 439
Copy link

Copilot AI Nov 26, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new endpoint printing functionality introduced in the PrintEndpointsAsync method and the pipeline step configuration lacks test coverage. Consider adding tests to verify:

  • Endpoint discovery and display when containers are running
  • Behavior when no external endpoints are configured
  • Handling of multiple endpoints with different schemes
  • Error handling when Docker Compose commands fail

The test file tests/Aspire.Hosting.Docker.Tests/DockerComposePublisherTests.cs has comprehensive test coverage for other Docker Compose functionality and would be an appropriate location for these tests.

Copilot uses AI. Check for mistakes.
/// Represents the JSON output from docker compose ps --format json.
/// </summary>
internal sealed class DockerComposeServiceInfo
{
public string? Service { get; set; }
public List<DockerComposePublisher>? Publishers { get; set; }
}

/// <summary>
/// Represents a port publisher in docker compose ps output.
/// </summary>
internal sealed class DockerComposePublisher
{
public int? PublishedPort { get; set; }
public int? TargetPort { get; set; }
}
}

[JsonSerializable(typeof(DockerComposeServiceResource.DockerComposeServiceInfo))]
internal sealed partial class DockerComposeJsonContext : JsonSerializerContext
{
}