File: DockerComposePublishingContext.cs
Web Access
Project: src\src\Aspire.Hosting.Docker\Aspire.Hosting.Docker.csproj (Aspire.Hosting.Docker)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Globalization;
using Aspire.Hosting.ApplicationModel;
using Aspire.Hosting.Docker.Resources;
using Aspire.Hosting.Docker.Resources.ComposeNodes;
using Aspire.Hosting.Docker.Resources.ServiceNodes;
using Aspire.Hosting.Publishing;
using Microsoft.Extensions.Logging;
 
namespace Aspire.Hosting.Docker;
 
/// <summary>
/// Represents a context for publishing Docker Compose configurations for a distributed application.
/// </summary>
/// <remarks>
/// This context facilitates the generation of Docker Compose files using the provided application model,
/// publisher options, and execution context. It handles the allocation of ports for services and ensures
/// that the Docker Compose configuration file is created in the specified output path.
/// </remarks>
internal sealed class DockerComposePublishingContext(
    DistributedApplicationExecutionContext executionContext,
    DockerComposePublisherOptions publisherOptions,
    ILogger logger,
    CancellationToken cancellationToken = default)
{
    public readonly PortAllocator PortAllocator = new();
    private readonly Dictionary<string, (string Description, string? DefaultValue)> _env = [];
    private readonly Dictionary<IResource, ComposeServiceContext> _composeServices = [];
 
    private ILogger Logger => logger;
 
    internal async Task WriteModel(DistributedApplicationModel model)
    {
        if (executionContext.IsRunMode)
        {
            return;
        }
 
        logger.StartGeneratingDockerCompose();
 
        ArgumentNullException.ThrowIfNull(model);
        ArgumentNullException.ThrowIfNull(publisherOptions.OutputPath);
 
        if (model.Resources.Count == 0)
        {
            logger.EmptyModel();
            return;
        }
 
        await WriteDockerComposeOutput(model).ConfigureAwait(false);
 
        logger.FinishGeneratingDockerCompose(publisherOptions.OutputPath);
    }
 
    public void AddEnv(string name, string description, string? defaultValue = null)
    {
        _env[name] = (description, defaultValue);
    }
 
    private async Task WriteDockerComposeOutput(DistributedApplicationModel model)
    {
        var defaultNetwork = new Network
        {
            Name = publisherOptions.ExistingNetworkName ?? "aspire",
            Driver = "bridge",
        };
 
        var composeFile = new ComposeFile();
        composeFile.AddNetwork(defaultNetwork);
 
        foreach (var resource in model.Resources)
        {
            if (resource.TryGetLastAnnotation<ManifestPublishingCallbackAnnotation>(out var lastAnnotation) &&
                lastAnnotation == ManifestPublishingCallbackAnnotation.Ignore)
            {
                continue;
            }
 
            if (!resource.IsContainer() && resource is not ProjectResource)
            {
                continue;
            }
 
            var composeServiceContext = await ProcessResourceAsync(resource).ConfigureAwait(false);
 
            var composeService = composeServiceContext.BuildComposeService();
 
            HandleComposeFileVolumes(composeServiceContext, composeFile);
 
            composeService.Networks =
            [
                defaultNetwork.Name,
            ];
 
            composeFile.AddService(composeService);
        }
 
        var composeOutput = composeFile.ToYaml();
        var outputFile = Path.Combine(publisherOptions.OutputPath!, "docker-compose.yaml");
        Directory.CreateDirectory(publisherOptions.OutputPath!);
        await File.WriteAllTextAsync(outputFile, composeOutput, cancellationToken).ConfigureAwait(false);
 
        if (_env.Count == 0)
        {
            // No environment variables to write, so we can skip creating the .env file
            return;
        }
 
        // Write a .env file with the environment variable names
        // that are used in the compose file
        var envFile = Path.Combine(publisherOptions.OutputPath!, ".env");
        using var envWriter = new StreamWriter(envFile);
 
        foreach (var entry in _env)
        {
            var (key, (description, defaultValue)) = entry;
 
            await envWriter.WriteLineAsync($"# {description}").ConfigureAwait(false);
 
            if (defaultValue is not null)
            {
                await envWriter.WriteLineAsync($"{key}={defaultValue}").ConfigureAwait(false);
            }
            else
            {
                await envWriter.WriteLineAsync($"{key}=").ConfigureAwait(false);
            }
 
            await envWriter.WriteLineAsync().ConfigureAwait(false);
        }
 
        await envWriter.FlushAsync().ConfigureAwait(false);
    }
 
    private async Task<ComposeServiceContext> ProcessResourceAsync(IResource resource)
    {
        if (!_composeServices.TryGetValue(resource, out var context))
        {
            _composeServices[resource] = context = new(resource, this);
            await context.ProcessResourceAsync(executionContext, cancellationToken).ConfigureAwait(false);
        }
 
        return context;
    }
 
    private static void HandleComposeFileVolumes(ComposeServiceContext composeServiceContext, ComposeFile composeFile)
    {
        foreach (var volume in composeServiceContext.Volumes.Where(volume => volume.Type != "bind"))
        {
            if (composeFile.Volumes.ContainsKey(volume.Name))
            {
                continue;
            }
 
            var newVolume = new Volume
            {
                Name = volume.Name,
                Driver = volume.Driver ?? "local",
                External = volume.External,
            };
 
            composeFile.AddVolume(newVolume);
        }
    }
 
    private sealed class ComposeServiceContext(IResource resource, DockerComposePublishingContext composePublishingContext)
    {
        private record struct EndpointMapping(string Scheme, string Host, int InternalPort, int ExposedPort, bool IsHttpIngress, bool External);
 
        private readonly Dictionary<string, EndpointMapping> _endpointMapping = [];
        public Dictionary<string, string> EnvironmentVariables { get; } = [];
        public List<string> Commands { get; } = [];
        public Dictionary<string, object> Parameters { get; } = [];
 
        public List<Volume> Volumes { get; } = [];
 
        public Service BuildComposeService()
        {
            if (!TryGetContainerImageName(resource, out var containerImageName))
            {
                composePublishingContext.Logger.FailedToGetContainerImage(resource.Name);
            }
 
            var composeService = new Service
            {
                Name = resource.Name.ToLowerInvariant(),
            };
 
            SetEntryPoint(composeService);
            AddEnvironmentVariablesAndCommandLineArgs(composeService);
            AddPorts(composeService);
            AddVolumes(composeService);
            SetContainerImage(containerImageName, composeService);
 
            return composeService;
        }
 
        private void AddVolumes(Service composeService)
        {
            if (Volumes.Count == 0)
            {
                return;
            }
 
            foreach (var volume in Volumes)
            {
                composeService.AddVolume(volume);
            }
        }
 
        private void AddPorts(Service composeService)
        {
            if (_endpointMapping.Count == 0)
            {
                return;
            }
 
            foreach (var (_, mapping) in _endpointMapping)
            {
                var internalPort = mapping.InternalPort.ToString(CultureInfo.InvariantCulture);
                var exposedPort = mapping.ExposedPort.ToString(CultureInfo.InvariantCulture);
 
                composeService.Ports.Add($"{exposedPort}:{internalPort}");
            }
        }
 
        private static void SetContainerImage(string? containerImageName, Service composeService)
        {
            if (containerImageName is not null)
            {
                composeService.Image = containerImageName;
            }
        }
 
        private bool TryGetContainerImageName(IResource resource, 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 (resource.TryGetLastAnnotation<DockerfileBuildAnnotation>(out _) || resource is ProjectResource)
            {
                var imageEnvName = $"{resource.Name.ToUpperInvariant().Replace("-", "_")}_IMAGE";
 
                composePublishingContext.AddEnv(imageEnvName,
                                                $"Container image name for {resource.Name}",
                                                $"{resource.Name}:latest");
 
                containerImageName = $"${{{imageEnvName}}}";
                return false;
            }
 
            return resource.TryGetContainerImageName(out containerImageName);
        }
 
        public async Task ProcessResourceAsync(DistributedApplicationExecutionContext executionContext, CancellationToken cancellationToken)
        {
            ProcessEndpoints();
            ProcessVolumes();
 
            await ProcessEnvironmentAsync(executionContext, cancellationToken).ConfigureAwait(false);
            await ProcessArgumentsAsync(cancellationToken).ConfigureAwait(false);
        }
 
        private void ProcessEndpoints()
        {
            if (!resource.TryGetEndpoints(out var endpoints))
            {
                return;
            }
 
            foreach (var endpoint in endpoints)
            {
                var internalPort = endpoint.TargetPort ?? composePublishingContext.PortAllocator.AllocatePort();
                composePublishingContext.PortAllocator.AddUsedPort(internalPort);
 
                var exposedPort = composePublishingContext.PortAllocator.AllocatePort();
                composePublishingContext.PortAllocator.AddUsedPort(exposedPort);
 
                _endpointMapping[endpoint.Name] = new(endpoint.UriScheme, resource.Name, internalPort, exposedPort, false, endpoint.IsExternal);
            }
        }
 
        private async Task ProcessArgumentsAsync(CancellationToken cancellationToken)
        {
            if (resource.TryGetAnnotationsOfType<CommandLineArgsCallbackAnnotation>(out var commandLineArgsCallbackAnnotations))
            {
                var context = new CommandLineArgsCallbackContext([], cancellationToken: cancellationToken);
 
                foreach (var c in commandLineArgsCallbackAnnotations)
                {
                    await c.Callback(context).ConfigureAwait(false);
                }
 
                foreach (var arg in context.Args)
                {
                    var value = await ProcessValueAsync(arg).ConfigureAwait(false);
 
                    if (value is not string str)
                    {
                        throw new NotSupportedException("Command line args must be strings");
                    }
 
                    Commands.Add(new(str));
                }
            }
        }
 
        private async Task ProcessEnvironmentAsync(DistributedApplicationExecutionContext executionContext, CancellationToken cancellationToken)
        {
            if (resource.TryGetAnnotationsOfType<EnvironmentCallbackAnnotation>(out var environmentCallbacks))
            {
                var context = new EnvironmentCallbackContext(executionContext, cancellationToken: cancellationToken);
 
                foreach (var c in environmentCallbacks)
                {
                    await c.Callback(context).ConfigureAwait(false);
                }
 
                foreach (var kv in context.EnvironmentVariables)
                {
                    var value = await ProcessValueAsync(kv.Value).ConfigureAwait(false);
 
                    EnvironmentVariables[kv.Key] = value.ToString() ?? string.Empty;
                }
            }
        }
        private void ProcessVolumes()
        {
            if (!resource.TryGetContainerMounts(out var mounts))
            {
                return;
            }
 
            foreach (var volume in mounts)
            {
                if (volume.Source is null || volume.Target is null)
                {
                    throw new InvalidOperationException("Volume source and target must be set");
                }
 
                var composeVolume = new Volume
                {
                    Name = volume.Source,
                    Type = volume.Type == ContainerMountType.BindMount ? "bind" : "volume",
                    Target = volume.Target,
                    Source = volume.Source,
                    ReadOnly = volume.IsReadOnly,
                };
 
                Volumes.Add(composeVolume);
            }
        }
 
        private static string GetValue(EndpointMapping mapping, EndpointProperty property)
        {
            var (scheme, host, internalPort, exposedPort, isHttpIngress, _) = mapping;
 
            return property switch
            {
                EndpointProperty.Url => GetHostValue($"{scheme}://", suffix: isHttpIngress ? null : $":{internalPort}"),
                EndpointProperty.Host or EndpointProperty.IPV4Host => GetHostValue(),
                EndpointProperty.Port => internalPort.ToString(CultureInfo.InvariantCulture),
                EndpointProperty.HostAndPort => GetHostValue(suffix: $":{internalPort}"),
                EndpointProperty.TargetPort => $"{exposedPort}",
                EndpointProperty.Scheme => scheme,
                _ => throw new NotSupportedException(),
            };
 
            string GetHostValue(string? prefix = null, string? suffix = null)
            {
                return $"{prefix}{host}{suffix}";
            }
        }
 
        private async Task<object> ProcessValueAsync(object value)
        {
            while (true)
            {
                if (value is string s)
                {
                    return s;
                }
 
                if (value is EndpointReference ep)
                {
                    var context = ep.Resource == resource
                        ? this
                        : await composePublishingContext.ProcessResourceAsync(ep.Resource)
                            .ConfigureAwait(false);
 
                    var mapping = context._endpointMapping[ep.EndpointName];
 
                    var url = GetValue(mapping, EndpointProperty.Url);
 
                    return url;
                }
 
                if (value is ParameterResource param)
                {
                    return AllocateParameter(param);
                }
 
                if (value is ConnectionStringReference cs)
                {
                    value = cs.Resource.ConnectionStringExpression;
                    continue;
                }
 
                if (value is IResourceWithConnectionString csrs)
                {
                    value = csrs.ConnectionStringExpression;
                    continue;
                }
 
                if (value is EndpointReferenceExpression epExpr)
                {
                    var context = epExpr.Endpoint.Resource == resource
                        ? this
                        : await composePublishingContext.ProcessResourceAsync(epExpr.Endpoint.Resource).ConfigureAwait(false);
 
                    var mapping = context._endpointMapping[epExpr.Endpoint.EndpointName];
 
                    var val = GetValue(mapping, epExpr.Property);
 
                    return val;
                }
 
                if (value is ReferenceExpression expr)
                {
                    if (expr is { Format: "{0}", ValueProviders.Count: 1 })
                    {
                        return (await ProcessValueAsync(expr.ValueProviders[0]).ConfigureAwait(false)).ToString() ?? string.Empty;
                    }
 
                    var args = new object[expr.ValueProviders.Count];
                    var index = 0;
 
                    foreach (var vp in expr.ValueProviders)
                    {
                        var val = await ProcessValueAsync(vp).ConfigureAwait(false);
                        args[index++] = val ?? throw new InvalidOperationException("Value is null");
                    }
 
                    return string.Format(CultureInfo.InvariantCulture, expr.Format, args);
                }
 
                // If we don't know how to process the value, we just return it as an external reference
                if (value is IManifestExpressionProvider r)
                {
                    composePublishingContext.Logger.NotSupportedResourceWarning(nameof(value), r.GetType().Name);
 
                    return ResolveUnknownValue(r);
                }
 
                return value; // todo: we need to never get here really...
            }
        }
 
        private string ResolveUnknownValue(IManifestExpressionProvider parameter)
        {
            // Placeholder for resolving the actual parameter value
            // https://docs.docker.com/compose/how-tos/environment-variables/variable-interpolation/#interpolation-syntax
 
            // Treat secrets as environment variable placeholders as for now
            // this doesn't handle generation of parameter values with defaults
            var env = parameter.ValueExpression.Replace("{", "")
                     .Replace("}", "")
                     .Replace(".", "_")
                     .Replace("-", "_")
                     .ToUpperInvariant();
 
            composePublishingContext.AddEnv(env, $"Unknown reference {parameter.ValueExpression}");
 
            return $"${{{env}}}";
        }
 
        private string ResolveParameterValue(ParameterResource parameter)
        {
            // Placeholder for resolving the actual parameter value
            // https://docs.docker.com/compose/how-tos/environment-variables/variable-interpolation/#interpolation-syntax
 
            // Treat secrets as environment variable placeholders as for now
            // this doesn't handle generation of parameter values with defaults
            var env = parameter.Name.ToUpperInvariant().Replace("-", "_");
 
            composePublishingContext.AddEnv(env, $"Parameter {parameter.Name}",
                                            parameter.Secret || parameter.Default is null ? null : parameter.Value);
 
            return $"${{{env}}}";
        }
 
        private string AllocateParameter(ParameterResource parameter)
        {
            return ResolveParameterValue(parameter);
        }
 
        private void SetEntryPoint(Service composeService)
        {
            if (resource is ContainerResource { Entrypoint: { } entrypoint })
            {
                composeService.Entrypoint.Add(entrypoint);
            }
        }
 
        private void AddEnvironmentVariablesAndCommandLineArgs(Service composeService)
        {
            if (EnvironmentVariables.Count > 0)
            {
                foreach (var variable in EnvironmentVariables)
                {
                    composeService.AddEnvironmentalVariable(variable.Key, variable.Value);
                }
            }
 
            if (Commands.Count > 0)
            {
                composeService.Command.AddRange(Commands);
            }
        }
    }
}