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+
46using System . Globalization ;
57using System . Text ;
8+ using System . Text . Json ;
9+ using System . Text . Json . Serialization ;
610using Aspire . Hosting . ApplicationModel ;
11+ using Aspire . Hosting . Dcp . Process ;
712using Aspire . Hosting . Docker . Resources . ComposeNodes ;
813using Aspire . Hosting . Docker . Resources . ServiceNodes ;
14+ using Aspire . Hosting . Pipelines ;
15+ using Aspire . Hosting . Utils ;
16+ using Microsoft . Extensions . Logging ;
917
1018namespace 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