Skip to content

Commit 3c202d9

Browse files
committed
Display resource endpoints for Docker Compose deploy
1 parent 95982c4 commit 3c202d9

File tree

2 files changed

+243
-37
lines changed

2 files changed

+243
-37
lines changed

src/Aspire.Hosting.Docker/DockerComposeEnvironmentResource.cs

Lines changed: 39 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,11 @@ public DockerComposeEnvironmentResource(string name) : base(name)
152152
annotation.Callback(context);
153153
}
154154
}
155+
156+
// Ensure print-summary steps from deployment targets run after docker-compose-up
157+
var printSummarySteps = context.GetSteps(deploymentTarget, "print-summary");
158+
var dockerComposeUpSteps = context.GetSteps(this, "docker-compose-up");
159+
printSummarySteps.DependsOn(dockerComposeUpSteps);
155160
}
156161

157162
// This ensures that resources that have to be built before deployments are handled
@@ -200,7 +205,6 @@ private async Task DockerComposeUpAsync(PipelineStepContext context)
200205
{
201206
var outputPath = PublishingContextUtils.GetEnvironmentOutputPath(context, this);
202207
var dockerComposeFilePath = Path.Combine(outputPath, "docker-compose.yaml");
203-
var envFilePath = GetEnvFilePath(context);
204208

205209
if (!File.Exists(dockerComposeFilePath))
206210
{
@@ -212,17 +216,10 @@ private async Task DockerComposeUpAsync(PipelineStepContext context)
212216
{
213217
try
214218
{
215-
var projectName = GetDockerComposeProjectName(context);
216-
var arguments = $"compose -f \"{dockerComposeFilePath}\" --project-name \"{projectName}\"";
217-
218-
if (File.Exists(envFilePath))
219-
{
220-
arguments += $" --env-file \"{envFilePath}\"";
221-
}
222-
219+
var arguments = GetDockerComposeArguments(context, this);
223220
arguments += " up -d --remove-orphans";
224221

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

227224
var spec = new ProcessSpec("docker")
228225
{
@@ -270,7 +267,6 @@ private async Task DockerComposeDownAsync(PipelineStepContext context)
270267
{
271268
var outputPath = PublishingContextUtils.GetEnvironmentOutputPath(context, this);
272269
var dockerComposeFilePath = Path.Combine(outputPath, "docker-compose.yaml");
273-
var envFilePath = GetEnvFilePath(context);
274270

275271
if (!File.Exists(dockerComposeFilePath))
276272
{
@@ -282,17 +278,10 @@ private async Task DockerComposeDownAsync(PipelineStepContext context)
282278
{
283279
try
284280
{
285-
var projectName = GetDockerComposeProjectName(context);
286-
var arguments = $"compose -f \"{dockerComposeFilePath}\" --project-name \"{projectName}\"";
287-
288-
if (File.Exists(envFilePath))
289-
{
290-
arguments += $" --env-file \"{envFilePath}\"";
291-
}
292-
281+
var arguments = GetDockerComposeArguments(context, this);
293282
arguments += " down";
294283

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

297286
var spec = new ProcessSpec("docker")
298287
{
@@ -330,7 +319,7 @@ private async Task DockerComposeDownAsync(PipelineStepContext context)
330319

331320
private async Task PrepareAsync(PipelineStepContext context)
332321
{
333-
var envFilePath = GetEnvFilePath(context);
322+
var envFilePath = GetEnvFilePath(context, this);
334323

335324
if (CapturedEnvironmentVariables.Count == 0)
336325
{
@@ -367,7 +356,33 @@ internal string AddEnvironmentVariable(string name, string? description = null,
367356
return $"${{{name}}}";
368357
}
369358

370-
private string GetDockerComposeProjectName(PipelineStepContext context)
359+
internal static string GetEnvFilePath(PipelineStepContext context, DockerComposeEnvironmentResource environment)
360+
{
361+
var outputPath = PublishingContextUtils.GetEnvironmentOutputPath(context, environment);
362+
var hostEnvironment = context.Services.GetService<Microsoft.Extensions.Hosting.IHostEnvironment>();
363+
var environmentName = hostEnvironment?.EnvironmentName ?? environment.Name;
364+
var envFilePath = Path.Combine(outputPath, $".env.{environmentName}");
365+
return envFilePath;
366+
}
367+
368+
internal static string GetDockerComposeArguments(PipelineStepContext context, DockerComposeEnvironmentResource environment)
369+
{
370+
var outputPath = PublishingContextUtils.GetEnvironmentOutputPath(context, environment);
371+
var dockerComposeFilePath = Path.Combine(outputPath, "docker-compose.yaml");
372+
var envFilePath = GetEnvFilePath(context, environment);
373+
var projectName = GetDockerComposeProjectName(context, environment);
374+
375+
var arguments = $"compose -f \"{dockerComposeFilePath}\" --project-name \"{projectName}\"";
376+
377+
if (File.Exists(envFilePath))
378+
{
379+
arguments += $" --env-file \"{envFilePath}\"";
380+
}
381+
382+
return arguments;
383+
}
384+
385+
internal static string GetDockerComposeProjectName(PipelineStepContext context, DockerComposeEnvironmentResource environment)
371386
{
372387
// Get the AppHost:PathSha256 from configuration to disambiguate projects
373388
var configuration = context.Services.GetService<IConfiguration>();
@@ -377,19 +392,10 @@ private string GetDockerComposeProjectName(PipelineStepContext context)
377392
{
378393
// Use first 8 characters of the hash for readability
379394
// Format: aspire-{environmentName}-{sha8}
380-
return $"aspire-{Name.ToLowerInvariant()}-{appHostSha[..8].ToLowerInvariant()}";
395+
return $"aspire-{environment.Name.ToLowerInvariant()}-{appHostSha[..8].ToLowerInvariant()}";
381396
}
382397

383398
// Fallback to just using the environment name if PathSha256 is not available
384-
return $"aspire-{Name.ToLowerInvariant()}";
385-
}
386-
387-
private string GetEnvFilePath(PipelineStepContext context)
388-
{
389-
var outputPath = PublishingContextUtils.GetEnvironmentOutputPath(context, this);
390-
var hostEnvironment = context.Services.GetService<Microsoft.Extensions.Hosting.IHostEnvironment>();
391-
var environmentName = hostEnvironment?.EnvironmentName ?? Name;
392-
var envFilePath = Path.Combine(outputPath, $".env.{environmentName}");
393-
return envFilePath;
399+
return $"aspire-{environment.Name.ToLowerInvariant()}";
394400
}
395401
}

src/Aspire.Hosting.Docker/DockerComposeServiceResource.cs

Lines changed: 204 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,59 @@
11
// Licensed to the .NET Foundation under one or more agreements.
22
// The .NET Foundation licenses this file to you under the MIT license.
33

4+
#pragma warning disable ASPIREPIPELINES001
5+
46
using System.Globalization;
57
using System.Text;
8+
using System.Text.Json;
9+
using System.Text.Json.Serialization;
610
using Aspire.Hosting.ApplicationModel;
11+
using Aspire.Hosting.Dcp.Process;
712
using Aspire.Hosting.Docker.Resources.ComposeNodes;
813
using Aspire.Hosting.Docker.Resources.ServiceNodes;
14+
using Aspire.Hosting.Pipelines;
15+
using Aspire.Hosting.Utils;
16+
using Microsoft.Extensions.Logging;
917

1018
namespace Aspire.Hosting.Docker;
1119

1220
/// <summary>
1321
/// Represents a compute resource for Docker Compose with strongly-typed properties.
1422
/// </summary>
15-
public class DockerComposeServiceResource(string name, IResource resource, DockerComposeEnvironmentResource composeEnvironmentResource) : Resource(name), IResourceWithParent<DockerComposeEnvironmentResource>
23+
public class DockerComposeServiceResource : Resource, IResourceWithParent<DockerComposeEnvironmentResource>
1624
{
25+
private readonly IResource _targetResource;
26+
private readonly DockerComposeEnvironmentResource _composeEnvironmentResource;
27+
28+
/// <summary>
29+
/// Initializes a new instance of the <see cref="DockerComposeServiceResource"/> class.
30+
/// </summary>
31+
/// <param name="name">The name of the resource.</param>
32+
/// <param name="resource">The target resource.</param>
33+
/// <param name="composeEnvironmentResource">The Docker Compose environment resource.</param>
34+
public DockerComposeServiceResource(string name, IResource resource, DockerComposeEnvironmentResource composeEnvironmentResource) : base(name)
35+
{
36+
_targetResource = resource;
37+
_composeEnvironmentResource = composeEnvironmentResource;
38+
39+
// Add pipeline step annotation to display endpoints after deployment
40+
Annotations.Add(new PipelineStepAnnotation((factoryContext) =>
41+
{
42+
var steps = new List<PipelineStep>();
43+
44+
var printResourceSummary = new PipelineStep
45+
{
46+
Name = $"print-{resource.Name}-summary",
47+
Action = async ctx => await PrintEndpointsAsync(ctx, composeEnvironmentResource).ConfigureAwait(false),
48+
Tags = ["print-summary"],
49+
RequiredBySteps = [WellKnownPipelineSteps.Deploy]
50+
};
51+
52+
steps.Add(printResourceSummary);
53+
54+
return steps;
55+
}));
56+
}
1757
/// <summary>
1858
/// Most common shell executables used as container entrypoints in Linux containers.
1959
/// These are used to identify when a container's entrypoint is a shell that will execute commands.
@@ -44,7 +84,7 @@ internal record struct EndpointMapping(
4484
/// <summary>
4585
/// Gets the resource that is the target of this Docker Compose service.
4686
/// </summary>
47-
internal IResource TargetResource => resource;
87+
internal IResource TargetResource => _targetResource;
4888

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

69109
/// <inheritdoc/>
70-
public DockerComposeEnvironmentResource Parent => composeEnvironmentResource;
110+
public DockerComposeEnvironmentResource Parent => _composeEnvironmentResource;
71111

72112
internal Service BuildComposeService()
73113
{
74114
var composeService = new Service
75115
{
76-
Name = resource.Name.ToLowerInvariant(),
116+
Name = TargetResource.Name.ToLowerInvariant(),
77117
};
78118

79119
if (TryGetContainerImageName(TargetResource, out var containerImageName))
@@ -265,4 +305,164 @@ private void AddVolumes(Service composeService)
265305
composeService.AddVolume(volume);
266306
}
267307
}
308+
309+
private async Task PrintEndpointsAsync(PipelineStepContext context, DockerComposeEnvironmentResource environment)
310+
{
311+
var outputPath = PublishingContextUtils.GetEnvironmentOutputPath(context, environment);
312+
var dockerComposeFilePath = Path.Combine(outputPath, "docker-compose.yaml");
313+
314+
if (!File.Exists(dockerComposeFilePath))
315+
{
316+
context.Logger.LogWarning("Docker Compose file not found at {Path}", dockerComposeFilePath);
317+
return;
318+
}
319+
320+
try
321+
{
322+
// Use docker compose ps to get the running containers and their port mappings
323+
var arguments = DockerComposeEnvironmentResource.GetDockerComposeArguments(context, environment);
324+
arguments += " ps --format json";
325+
326+
var outputLines = new List<string>();
327+
328+
var spec = new ProcessSpec("docker")
329+
{
330+
Arguments = arguments,
331+
WorkingDirectory = outputPath,
332+
ThrowOnNonZeroReturnCode = false,
333+
InheritEnv = true,
334+
OnOutputData = output =>
335+
{
336+
if (!string.IsNullOrWhiteSpace(output))
337+
{
338+
outputLines.Add(output);
339+
}
340+
},
341+
OnErrorData = error =>
342+
{
343+
if (!string.IsNullOrWhiteSpace(error))
344+
{
345+
context.Logger.LogDebug("docker compose ps (stderr): {Error}", error);
346+
}
347+
}
348+
};
349+
350+
var (pendingProcessResult, processDisposable) = ProcessUtil.Run(spec);
351+
352+
await using (processDisposable)
353+
{
354+
var processResult = await pendingProcessResult
355+
.WaitAsync(context.CancellationToken)
356+
.ConfigureAwait(false);
357+
358+
if (processResult.ExitCode != 0)
359+
{
360+
context.Logger.LogWarning("Failed to query Docker Compose services for {ResourceName}. Exit code: {ExitCode}", TargetResource.Name, processResult.ExitCode);
361+
return;
362+
}
363+
364+
// Parse the JSON output to find port mappings for this service
365+
var serviceName = TargetResource.Name.ToLowerInvariant();
366+
var endpoints = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
367+
368+
// Get all external endpoint mappings for this resource
369+
var externalEndpointMappings = EndpointMappings.Values.Where(m => m.IsExternal).ToList();
370+
371+
// If there are no external endpoints configured, we're done
372+
if (externalEndpointMappings.Count == 0)
373+
{
374+
context.ReportingStep.Log(LogLevel.Information, $"Successfully deployed **{TargetResource.Name}** to Docker Compose environment **{environment.Name}**. No public endpoints were configured.", enableMarkdown: true);
375+
return;
376+
}
377+
378+
foreach (var line in outputLines)
379+
{
380+
try
381+
{
382+
var serviceInfo = JsonSerializer.Deserialize(line, DockerComposeJsonContext.Default.DockerComposeServiceInfo);
383+
384+
if (serviceInfo is null ||
385+
!string.Equals(serviceInfo.Service, serviceName, StringComparison.OrdinalIgnoreCase))
386+
{
387+
continue;
388+
}
389+
390+
if (serviceInfo.Publishers is not { Count: > 0 })
391+
{
392+
continue;
393+
}
394+
395+
foreach (var publisher in serviceInfo.Publishers)
396+
{
397+
// Skip ports that aren't actually published (port 0 or null means not exposed)
398+
if (publisher.PublishedPort is not > 0)
399+
{
400+
continue;
401+
}
402+
403+
// Try to find a matching external endpoint to get the scheme
404+
// Match by internal port (numeric) or by exposed port
405+
// InternalPort may be a placeholder like ${API_PORT} for projects, so also check ExposedPort
406+
var targetPortStr = publisher.TargetPort?.ToString(CultureInfo.InvariantCulture);
407+
var endpointMapping = externalEndpointMappings
408+
.FirstOrDefault(m => m.InternalPort == targetPortStr || m.ExposedPort == publisher.TargetPort);
409+
410+
// If we found a matching endpoint, use its scheme; otherwise default to http for external ports
411+
var scheme = endpointMapping.Scheme ?? "http";
412+
413+
// Only add if we found a matching external endpoint OR if scheme is http/https
414+
// (published ports are external by definition in docker compose)
415+
if (endpointMapping.IsExternal || scheme is "http" or "https")
416+
{
417+
var endpoint = $"{scheme}://localhost:{publisher.PublishedPort}";
418+
endpoints.Add(endpoint);
419+
}
420+
}
421+
}
422+
catch (JsonException ex)
423+
{
424+
context.Logger.LogDebug(ex, "Failed to parse docker compose ps output line: {Line}", line);
425+
}
426+
}
427+
428+
// Display the endpoints
429+
if (endpoints.Count > 0)
430+
{
431+
var endpointList = string.Join(", ", endpoints.Select(e => $"[{e}]({e})"));
432+
context.ReportingStep.Log(LogLevel.Information, $"Successfully deployed **{TargetResource.Name}** to {endpointList}", enableMarkdown: true);
433+
}
434+
else
435+
{
436+
context.ReportingStep.Log(LogLevel.Information, $"Successfully deployed **{TargetResource.Name}** to Docker Compose environment **{environment.Name}**. No public endpoints were configured.", enableMarkdown: true);
437+
}
438+
}
439+
}
440+
catch (Exception ex)
441+
{
442+
context.Logger.LogWarning(ex, "Failed to retrieve endpoints for {ResourceName}", TargetResource.Name);
443+
}
444+
}
445+
446+
/// <summary>
447+
/// Represents the JSON output from docker compose ps --format json.
448+
/// </summary>
449+
internal sealed class DockerComposeServiceInfo
450+
{
451+
public string? Service { get; set; }
452+
public List<DockerComposePublisher>? Publishers { get; set; }
453+
}
454+
455+
/// <summary>
456+
/// Represents a port publisher in docker compose ps output.
457+
/// </summary>
458+
internal sealed class DockerComposePublisher
459+
{
460+
public int? PublishedPort { get; set; }
461+
public int? TargetPort { get; set; }
462+
}
463+
}
464+
465+
[JsonSerializable(typeof(DockerComposeServiceResource.DockerComposeServiceInfo))]
466+
internal sealed partial class DockerComposeJsonContext : JsonSerializerContext
467+
{
268468
}

0 commit comments

Comments
 (0)