|
// 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 : 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(_ =>
{
var steps = new List<PipelineStep>();
var printResourceSummary = new PipelineStep
{
Name = $"print-{_targetResource.Name}-summary",
Action = async ctx => await PrintEndpointsAsync(ctx, _composeEnvironmentResource).ConfigureAwait(false),
Tags = ["print-summary"],
RequiredBySteps = [WellKnownPipelineSteps.Deploy]
};
steps.Add(printResourceSummary);
return steps;
}));
}
/// <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.
/// </summary>
private static readonly HashSet<string> s_shellExecutables = new(StringComparer.OrdinalIgnoreCase)
{
"/bin/sh",
"/bin/bash",
"/sh",
"/bash",
"sh",
"bash",
"/usr/bin/sh",
"/usr/bin/bash"
};
internal bool IsShellExec { get; private set; }
internal record struct EndpointMapping(
IResource Resource,
string Scheme,
string Host,
string InternalPort,
int? ExposedPort,
bool IsExternal,
string EndpointName);
/// <summary>
/// Gets the resource that is the target of this Docker Compose service.
/// </summary>
internal IResource TargetResource => _targetResource;
/// <summary>
/// Gets the collection of environment variables for the Docker Compose service.
/// </summary>
internal Dictionary<string, object> EnvironmentVariables { get; } = [];
/// <summary>
/// Gets the collection of commands to be executed by the Docker Compose service.
/// </summary>
internal List<object> Args { get; } = [];
/// <summary>
/// Gets the collection of volumes for the Docker Compose service.
/// </summary>
internal List<Volume> Volumes { get; } = [];
/// <summary>
/// Gets the mapping of endpoint names to their configurations.
/// </summary>
internal Dictionary<string, EndpointMapping> EndpointMappings { get; } = [];
/// <inheritdoc/>
public DockerComposeEnvironmentResource Parent => _composeEnvironmentResource;
internal Service BuildComposeService()
{
var composeService = new Service
{
Name = TargetResource.Name.ToLowerInvariant(),
};
if (TryGetContainerImageName(TargetResource, out var containerImageName))
{
SetContainerImage(containerImageName, composeService);
}
SetContainerName(composeService);
SetEntryPoint(composeService);
AddEnvironmentVariablesAndCommandLineArgs(composeService);
AddPorts(composeService);
AddVolumes(composeService);
SetDependsOn(composeService);
return composeService;
}
private bool TryGetContainerImageName(IResource resourceInstance, out string? containerImageName)
{
// If the resource has a Dockerfile build annotation, we don't have the image name
// it will come as a parameter
if (resourceInstance.TryGetLastAnnotation<DockerfileBuildAnnotation>(out _) || resourceInstance is ProjectResource)
{
containerImageName = this.AsContainerImagePlaceholder();
return true;
}
return resourceInstance.TryGetContainerImageName(out containerImageName);
}
private void SetContainerName(Service composeService)
{
if (TargetResource.TryGetLastAnnotation<ContainerNameAnnotation>(out var containerNameAnnotation))
{
composeService.ContainerName = containerNameAnnotation.Name;
}
}
private void SetEntryPoint(Service composeService)
{
if (TargetResource is ContainerResource { Entrypoint: { } entrypoint })
{
composeService.Entrypoint.Add(entrypoint);
if (s_shellExecutables.Contains(entrypoint))
{
IsShellExec = true;
}
}
}
private void SetDependsOn(Service composeService)
{
if (TargetResource.TryGetAnnotationsOfType<WaitAnnotation>(out var waitAnnotations))
{
foreach (var waitAnnotation in waitAnnotations)
{
// We can only wait on other compose services
if (waitAnnotation.Resource is ProjectResource || waitAnnotation.Resource.IsContainer())
{
// https://docs.docker.com/compose/how-tos/startup-order/#control-startup
composeService.DependsOn[waitAnnotation.Resource.Name.ToLowerInvariant()] = new()
{
Condition = waitAnnotation.WaitType switch
{
// REVIEW: This only works if the target service has health checks,
// revisit this when we have a way to add health checks to the compose service
// WaitType.WaitUntilHealthy => "service_healthy",
WaitType.WaitForCompletion => "service_completed_successfully",
_ => "service_started",
},
};
}
}
}
}
private static void SetContainerImage(string? containerImageName, Service composeService)
{
if (containerImageName is not null)
{
composeService.Image = containerImageName;
}
}
private void AddEnvironmentVariablesAndCommandLineArgs(Service composeService)
{
var env = new Dictionary<string, string>();
foreach (var kv in EnvironmentVariables)
{
var value = this.ProcessValue(kv.Value);
env[kv.Key] = value?.ToString() ?? string.Empty;
}
if (env.Count > 0)
{
foreach (var variable in env)
{
composeService.AddEnvironmentalVariable(variable.Key, variable.Value);
}
}
var args = new List<string>();
foreach (var arg in Args)
{
var value = this.ProcessValue(arg);
if (value is not string str)
{
throw new NotSupportedException("Command line args must be strings");
}
args.Add(str);
}
if (args.Count > 0)
{
if (IsShellExec)
{
var sb = new StringBuilder();
foreach (var command in args)
{
// Escape any environment variables expressions in the command
// to prevent them from being interpreted by the docker compose CLI
EnvVarEscaper.EscapeUnescapedEnvVars(command, sb);
composeService.Command.Add(sb.ToString());
sb.Clear();
}
}
else
{
composeService.Command.AddRange(args);
}
}
}
private void AddPorts(Service composeService)
{
if (EndpointMappings.Count == 0)
{
return;
}
var ports = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var expose = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var (_, mapping) in EndpointMappings)
{
// Resolve the internal port for the endpoint mapping
var internalPort = mapping.InternalPort;
if (mapping.IsExternal)
{
var exposedPort = mapping.ExposedPort?.ToString(CultureInfo.InvariantCulture);
// No explicit exposed port, let docker compose assign a random port
if (exposedPort is null)
{
ports.Add(internalPort);
}
else
{
// Explicit exposed port, map it to the internal port
ports.Add($"{exposedPort}:{internalPort}");
}
}
else
{
// Internal endpoints use expose with just internalPort
expose.Add(internalPort);
}
}
composeService.Ports.AddRange(ports);
composeService.Expose.AddRange(expose);
}
private void AddVolumes(Service composeService)
{
if (Volumes.Count == 0)
{
return;
}
foreach (var volume in Volumes)
{
composeService.AddVolume(volume);
}
}
private async Task PrintEndpointsAsync(PipelineStepContext context, DockerComposeEnvironmentResource environment)
{
var outputPath = PublishingContextUtils.GetEnvironmentOutputPath(context, environment);
// No external endpoints configured - this is valid for internal-only services
var externalEndpointMappings = EndpointMappings.Values.Where(m => m.IsExternal).ToList();
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;
}
// Query the running container for its published ports
var outputLines = await RunDockerComposePsAsync(context, environment, outputPath).ConfigureAwait(false);
var endpoints = outputLines is not null
? ParseServiceEndpoints(outputLines, externalEndpointMappings, context.Logger)
: [];
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
{
// No published ports found in docker compose ps output.
context.ReportingStep.Log(LogLevel.Information,
$"Successfully deployed **{TargetResource.Name}** to Docker Compose environment **{environment.Name}**.",
enableMarkdown: true);
}
}
/// <summary>
/// Runs 'docker compose ps --format json' to get container status and port mappings.
/// </summary>
/// <returns>List of JSON output lines, or null if the command failed.</returns>
private static async Task<List<string>?> RunDockerComposePsAsync(
PipelineStepContext context,
DockerComposeEnvironmentResource environment,
string outputPath)
{
var arguments = DockerComposeEnvironmentResource.GetDockerComposeArguments(context, environment);
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.LogDebug("docker compose ps failed with exit code {ExitCode}", processResult.ExitCode);
return null;
}
}
return outputLines;
}
/// <summary>
/// Parses the JSON output from 'docker compose ps' to extract endpoint URLs for this service.
/// </summary>
/// <example>
/// Example JSON line from 'docker compose ps --format json':
/// <code>
/// {"Service":"myservice","State":"running","Publishers":[{"URL":"","TargetPort":80,"PublishedPort":8080,"Protocol":"tcp"}]}
/// </code>
/// Note: PublishedPort is 0 when the port is exposed but not mapped to the host.
/// </example>
private HashSet<string> ParseServiceEndpoints(
List<string> outputLines,
List<EndpointMapping> externalEndpointMappings,
ILogger logger)
{
var endpoints = new HashSet<string>(StringComparers.EndpointAnnotationName);
var serviceName = TargetResource.Name.ToLowerInvariant();
foreach (var line in outputLines)
{
try
{
var serviceInfo = JsonSerializer.Deserialize(line, DockerComposeJsonContext.Default.DockerComposeServiceInfo);
// Skip if not our service
if (serviceInfo is null ||
!string.Equals(serviceInfo.Service, serviceName, StringComparisons.ResourceName))
{
continue;
}
// Skip if no published ports
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);
}
}
}
catch (JsonException ex)
{
logger.LogDebug(ex, "Failed to parse docker compose ps output line: {Line}", line);
}
}
return endpoints;
}
/// <summary>
/// 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
{
}
|