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.
 
#pragma warning disable ASPIREPUBLISHERS001
 
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,
    IResourceContainerImageBuilder imageBuilder,
    ILogger logger,
    CancellationToken cancellationToken = default)
{
    public readonly IResourceContainerImageBuilder ImageBuilder = imageBuilder;
    public readonly DockerComposePublisherOptions PublisherOptions = publisherOptions;
 
    internal async Task WriteModelAsync(DistributedApplicationModel model)
    {
        if (!executionContext.IsPublishMode)
        {
            logger.NotInPublishingMode();
            return;
        }
 
        logger.StartGeneratingDockerCompose();
 
        ArgumentNullException.ThrowIfNull(model);
        ArgumentNullException.ThrowIfNull(PublisherOptions.OutputPath);
 
        if (model.Resources.Count == 0)
        {
            logger.EmptyModel();
            return;
        }
 
        await WriteDockerComposeOutputAsync(model).ConfigureAwait(false);
 
        logger.FinishGeneratingDockerCompose(PublisherOptions.OutputPath);
    }
 
    private async Task WriteDockerComposeOutputAsync(DistributedApplicationModel model)
    {
        var dockerComposeEnvironments = model.Resources.OfType<DockerComposeEnvironmentResource>().ToArray();
 
        if (dockerComposeEnvironments.Length > 1)
        {
            throw new NotSupportedException("Multiple Docker Compose environments are not supported.");
        }
 
        var environment = dockerComposeEnvironments.FirstOrDefault();
 
        if (environment == null)
        {
            // No Docker Compose environment found
            throw new InvalidOperationException($"No Docker Compose environment found. Ensure a Docker Compose environment is registered by calling {nameof(DockerComposeEnvironmentExtensions.AddDockerComposeEnvironment)}.");
        }
 
        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.GetDeploymentTargetAnnotation()?.DeploymentTarget is DockerComposeServiceResource serviceResource)
            {
                if (PublisherOptions.BuildImages)
                {
                    await ImageBuilder.BuildImageAsync(serviceResource.TargetResource, cancellationToken).ConfigureAwait(false);
                }
 
                var composeService = serviceResource.ComposeService;
 
                HandleComposeFileVolumes(serviceResource, composeFile);
 
                composeService.Networks =
                [
                    defaultNetwork.Name,
                ];
 
                if (serviceResource.TargetResource.TryGetAnnotationsOfType<DockerComposeServiceCustomizationAnnotation>(out var annotations))
                {
                    foreach (var a in annotations)
                    {
                        a.Configure(serviceResource, composeService);
                    }
                }
 
                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 (environment.CapturedEnvironmentVariables.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 environment.CapturedEnvironmentVariables ?? [])
        {
            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 static void HandleComposeFileVolumes(DockerComposeServiceResource serviceResource, ComposeFile composeFile)
    {
        foreach (var volume in serviceResource.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);
        }
    }
}