From c5ae26b5035e434abd99e7a0641fd111bb21f64e Mon Sep 17 00:00:00 2001 From: Safia Abdalla Date: Wed, 16 Apr 2025 14:31:09 -0700 Subject: [PATCH 1/3] Model Docker Compose as compute environment --- .../CommandLineArgsExtensions.cs | 148 +++++++ .../DockerComposeEnvironmentExtensions.cs | 32 ++ .../DockerComposeEnvironmentResource.cs | 22 ++ .../DockerComposeInfrastructure.cs | 204 ++++++++++ .../DockerComposePublishingContext.cs | 371 +++--------------- .../DockerComposeServiceResource.cs | 41 ++ .../DockerComposePublisherTests.cs | 5 + 7 files changed, 497 insertions(+), 326 deletions(-) create mode 100644 src/Aspire.Hosting.Docker/CommandLineArgsExtensions.cs create mode 100644 src/Aspire.Hosting.Docker/DockerComposeEnvironmentExtensions.cs create mode 100644 src/Aspire.Hosting.Docker/DockerComposeEnvironmentResource.cs create mode 100644 src/Aspire.Hosting.Docker/DockerComposeInfrastructure.cs create mode 100644 src/Aspire.Hosting.Docker/DockerComposeServiceResource.cs diff --git a/src/Aspire.Hosting.Docker/CommandLineArgsExtensions.cs b/src/Aspire.Hosting.Docker/CommandLineArgsExtensions.cs new file mode 100644 index 00000000000..8f367b0cd05 --- /dev/null +++ b/src/Aspire.Hosting.Docker/CommandLineArgsExtensions.cs @@ -0,0 +1,148 @@ +// 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; +using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.Docker; + +internal static class CommandLineArgsExtensions +{ + internal static async Task ProcessValueAsync(this DockerComposeServiceResource resource, DockerComposeInfrastructure.DockerComposeEnvironmentContext context, DistributedApplicationExecutionContext executionContext, object value) + { + while (true) + { + if (value is string s) + { + return s; + } + + if (value is EndpointReference ep) + { + var referencedResource = ep.Resource == resource + ? resource + : await context.CreateDockerComposeServiceResourceAsync(ep.Resource, executionContext, default).ConfigureAwait(false); + + var mapping = referencedResource.EndpointMappings[ep.EndpointName]; + + var url = GetValue(mapping, EndpointProperty.Url); + + return url; + } + + if (value is ParameterResource param) + { + return AllocateParameter(param, context); + } + + 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 referencedResource = epExpr.Endpoint.Resource == resource + ? resource + : await context.CreateDockerComposeServiceResourceAsync(epExpr.Endpoint.Resource, executionContext, default).ConfigureAwait(false); + + var mapping = referencedResource.EndpointMappings[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 resource.ProcessValueAsync(context, executionContext, 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 resource.ProcessValueAsync(context, executionContext, 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) + { + return ResolveUnknownValue(r, resource); + } + + return value; // todo: we need to never get here really... + } + } + + private static string GetValue(DockerComposeServiceResource.EndpointMapping mapping, EndpointProperty property) + { + return property switch + { + EndpointProperty.Url => GetHostValue($"{mapping.Scheme}://", suffix: mapping.IsHttpIngress ? null : $":{mapping.InternalPort}"), + EndpointProperty.Host or EndpointProperty.IPV4Host => GetHostValue(), + EndpointProperty.Port => mapping.InternalPort.ToString(CultureInfo.InvariantCulture), + EndpointProperty.HostAndPort => GetHostValue(suffix: $":{mapping.InternalPort}"), + EndpointProperty.TargetPort => $"{mapping.InternalPort}", + EndpointProperty.Scheme => mapping.Scheme, + _ => throw new NotSupportedException(), + }; + + string GetHostValue(string? prefix = null, string? suffix = null) + { + return $"{prefix}{mapping.Host}{suffix}"; + } + } + + private static string ResolveParameterValue(ParameterResource parameter, DockerComposeInfrastructure.DockerComposeEnvironmentContext context) + { + // 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("-", "_"); + + context.AddEnv(env, $"Parameter {parameter.Name}", + parameter.Secret || parameter.Default is null ? null : parameter.Value); + + return $"${{{env}}}"; + } + + private static string AllocateParameter(ParameterResource parameter, DockerComposeInfrastructure.DockerComposeEnvironmentContext context) + { + return ResolveParameterValue(parameter, context); + } + + private static string ResolveUnknownValue(IManifestExpressionProvider parameter, DockerComposeServiceResource serviceResource) + { + // 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(); + + serviceResource.EnvironmentVariables.Add(env, $"Unknown reference {parameter.ValueExpression}"); + + return $"${{{env}}}"; + } +} diff --git a/src/Aspire.Hosting.Docker/DockerComposeEnvironmentExtensions.cs b/src/Aspire.Hosting.Docker/DockerComposeEnvironmentExtensions.cs new file mode 100644 index 00000000000..404c4e93b39 --- /dev/null +++ b/src/Aspire.Hosting.Docker/DockerComposeEnvironmentExtensions.cs @@ -0,0 +1,32 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.Docker; +using Aspire.Hosting.Lifecycle; + +namespace Aspire.Hosting; + +/// +/// Provides extension methods for adding Docker Compose environment resources to the application model. +/// +public static class DockerComposeEnvironmentExtensions +{ + /// + /// Adds a Docker Compose environment to the application model. + /// + /// The . + /// The name of the Docker Compose environment resource. + /// A reference to the . + public static IResourceBuilder AddDockerComposeEnvironment( + this IDistributedApplicationBuilder builder, + string name) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrEmpty(name); + + var resource = new DockerComposeEnvironmentResource(name); + builder.Services.TryAddLifecycleHook(); + return builder.AddResource(resource); + } +} diff --git a/src/Aspire.Hosting.Docker/DockerComposeEnvironmentResource.cs b/src/Aspire.Hosting.Docker/DockerComposeEnvironmentResource.cs new file mode 100644 index 00000000000..c51c74f825e --- /dev/null +++ b/src/Aspire.Hosting.Docker/DockerComposeEnvironmentResource.cs @@ -0,0 +1,22 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Hosting.ApplicationModel; + +namespace Aspire.Hosting.Docker; + +/// +/// Represents a Docker Compose environment resource that can host application resources. +/// +/// +/// Initializes a new instance of the class. +/// +/// The name of the Docker Compose environment. +public class DockerComposeEnvironmentResource(string name) : Resource(name) +{ + /// + /// Gets the collection of environment variables captured from the Docker Compose environment. + /// These will be populated into a top-level .env file adjacent to the Docker Compose file. + /// + internal Dictionary CapturedEnvironmentVariables { get; } = []; +} diff --git a/src/Aspire.Hosting.Docker/DockerComposeInfrastructure.cs b/src/Aspire.Hosting.Docker/DockerComposeInfrastructure.cs new file mode 100644 index 00000000000..c4f1e0b0154 --- /dev/null +++ b/src/Aspire.Hosting.Docker/DockerComposeInfrastructure.cs @@ -0,0 +1,204 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.Lifecycle; +using Aspire.Hosting.Publishing; +using Microsoft.Extensions.Logging; + +namespace Aspire.Hosting.Docker; + +/// +/// Represents the infrastructure for Docker Compose within the Aspire Hosting environment. +/// Implements the interface to provide lifecycle hooks for distributed applications. +/// +internal sealed class DockerComposeInfrastructure( + ILogger logger, + DistributedApplicationExecutionContext executionContext) : IDistributedApplicationLifecycleHook +{ + public async Task BeforeStartAsync(DistributedApplicationModel appModel, CancellationToken cancellationToken = default) + { + if (executionContext.IsRunMode) + { + return; + } + + // Find Docker Compose environment resources + var dockerComposeEnvironments = appModel.Resources.OfType().ToArray(); + + if (dockerComposeEnvironments.Length > 1) + { + throw new NotSupportedException("Multiple Docker Compose environments are not supported."); + } + + var environment = dockerComposeEnvironments.FirstOrDefault(); + + if (environment == null) + { + return; + } + + var dockerComposeEnvironmentContext = new DockerComposeEnvironmentContext(environment, logger); + + foreach (var r in appModel.Resources) + { + if (r.TryGetLastAnnotation(out var lastAnnotation) && lastAnnotation == ManifestPublishingCallbackAnnotation.Ignore) + { + continue; + } + + // Skip resources that are not containers or projects + if (!r.IsContainer() && r is not ProjectResource) + { + continue; + } + + // Create a Docker Compose compute resource for the resource + var serviceResource = await dockerComposeEnvironmentContext.CreateDockerComposeServiceResourceAsync(r, executionContext, cancellationToken).ConfigureAwait(false); + + // Add deployment target annotation to the resource + r.Annotations.Add(new DeploymentTargetAnnotation(serviceResource)); + } + } + + internal sealed class DockerComposeEnvironmentContext(DockerComposeEnvironmentResource environment, ILogger logger) + { + private readonly Dictionary _resourceMapping = []; + private readonly PortAllocator _portAllocator = new(); + + public async Task CreateDockerComposeServiceResourceAsync(IResource resource, DistributedApplicationExecutionContext executionContext, CancellationToken cancellationToken) + { + if (_resourceMapping.TryGetValue(resource, out var existingResource)) + { + return existingResource; + } + + logger.LogInformation("Creating Docker Compose resource for {ResourceName}", resource.Name); + + var serviceResource = new DockerComposeServiceResource(resource.Name, resource); + _resourceMapping[resource] = serviceResource; + + // Process endpoints + ProcessEndpoints(serviceResource); + + // Process volumes + ProcessVolumes(serviceResource); + + // Process environment variables + await ProcessEnvironmentVariablesAsync(serviceResource, executionContext, cancellationToken).ConfigureAwait(false); + + // Process command line arguments + await ProcessArgumentsAsync(serviceResource, executionContext, cancellationToken).ConfigureAwait(false); + + return serviceResource; + } + + private void ProcessEndpoints(DockerComposeServiceResource serviceResource) + { + if (!serviceResource.TargetResource.TryGetEndpoints(out var endpoints)) + { + return; + } + + foreach (var endpoint in endpoints) + { + var internalPort = endpoint.TargetPort ?? _portAllocator.AllocatePort(); + _portAllocator.AddUsedPort(internalPort); + + var exposedPort = _portAllocator.AllocatePort(); + _portAllocator.AddUsedPort(exposedPort); + + serviceResource.EndpointMappings.Add(endpoint.Name, new(endpoint.UriScheme, serviceResource.TargetResource.Name, internalPort, exposedPort, false)); + } + } + + private static void ProcessVolumes(DockerComposeServiceResource serviceResource) + { + if (!serviceResource.TargetResource.TryGetContainerMounts(out var mounts)) + { + return; + } + + foreach (var mount in mounts) + { + if (mount.Source is null || mount.Target is null) + { + throw new InvalidOperationException("Volume source and target must be set"); + } + + serviceResource.Volumes.Add(new Resources.ServiceNodes.Volume + { + Name = mount.Source, + Source = mount.Source, + Target = mount.Target, + Type = mount.Type == ContainerMountType.BindMount ? "bind" : "volume", + ReadOnly = mount.IsReadOnly + }); + } + } + + private async Task ProcessEnvironmentVariablesAsync(DockerComposeServiceResource serviceResource, DistributedApplicationExecutionContext executionContext, CancellationToken cancellationToken) + { + if (serviceResource.TargetResource.TryGetAnnotationsOfType(out var environmentCallbacks)) + { + var context = new EnvironmentCallbackContext(executionContext, serviceResource.TargetResource, cancellationToken: cancellationToken); + + foreach (var callback in environmentCallbacks) + { + await callback.Callback(context).ConfigureAwait(false); + } + + // Remove HTTPS service discovery variables as Docker Compose doesn't handle certificates + RemoveHttpsServiceDiscoveryVariables(context.EnvironmentVariables); + + foreach (var kv in context.EnvironmentVariables) + { + var value = await serviceResource.ProcessValueAsync(this, executionContext, kv.Value).ConfigureAwait(false); + serviceResource.EnvironmentVariables.Add(kv.Key, value?.ToString() ?? string.Empty); + } + } + } + + private static void RemoveHttpsServiceDiscoveryVariables(Dictionary environmentVariables) + { + var keysToRemove = environmentVariables + .Where(kvp => kvp.Value is EndpointReference epRef && epRef.Scheme == "https" && kvp.Key.StartsWith("services__")) + .Select(kvp => kvp.Key) + .ToList(); + + foreach (var key in keysToRemove) + { + environmentVariables.Remove(key); + } + } + + private async Task ProcessArgumentsAsync(DockerComposeServiceResource serviceResource, DistributedApplicationExecutionContext executionContext, CancellationToken cancellationToken) + { + if (serviceResource.TargetResource.TryGetAnnotationsOfType(out var commandLineArgsCallbacks)) + { + var context = new CommandLineArgsCallbackContext([], cancellationToken: cancellationToken); + + foreach (var callback in commandLineArgsCallbacks) + { + await callback.Callback(context).ConfigureAwait(false); + } + + foreach (var arg in context.Args) + { + var value = await serviceResource.ProcessValueAsync(this, executionContext, arg).ConfigureAwait(false); + if (value is not string str) + { + throw new NotSupportedException("Command line args must be strings"); + } + + serviceResource.Commands.Add(str); + } + } + } + + public void AddEnv(string name, string description, string? defaultValue = null) + { + environment.CapturedEnvironmentVariables[name] = (description, defaultValue); + } + } +} diff --git a/src/Aspire.Hosting.Docker/DockerComposePublishingContext.cs b/src/Aspire.Hosting.Docker/DockerComposePublishingContext.cs index 27d831b2c46..81e9e5348c1 100644 --- a/src/Aspire.Hosting.Docker/DockerComposePublishingContext.cs +++ b/src/Aspire.Hosting.Docker/DockerComposePublishingContext.cs @@ -31,9 +31,6 @@ internal sealed class DockerComposePublishingContext( { public readonly IResourceContainerImageBuilder ImageBuilder = imageBuilder; public readonly DockerComposePublisherOptions PublisherOptions = publisherOptions; - public readonly PortAllocator PortAllocator = new(); - private readonly Dictionary _env = []; - private readonly Dictionary _composeServices = []; private ILogger Logger => logger; @@ -61,13 +58,23 @@ internal async Task WriteModelAsync(DistributedApplicationModel model) logger.FinishGeneratingDockerCompose(PublisherOptions.OutputPath); } - public void AddEnv(string name, string description, string? defaultValue = null) - { - _env[name] = (description, defaultValue); - } - private async Task WriteDockerComposeOutputAsync(DistributedApplicationModel model) { + var dockerComposeEnvironments = model.Resources.OfType().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 + return; + } + var defaultNetwork = new Network { Name = PublisherOptions.ExistingNetworkName ?? "aspire", @@ -79,29 +86,21 @@ private async Task WriteDockerComposeOutputAsync(DistributedApplicationModel mod foreach (var resource in model.Resources) { - if (resource.TryGetLastAnnotation(out var lastAnnotation) && - lastAnnotation == ManifestPublishingCallbackAnnotation.Ignore) - { - continue; - } - - if (!resource.IsContainer() && resource is not ProjectResource) + if (resource.TryGetLastAnnotation(out var deploymentTargetAnnotation) && + deploymentTargetAnnotation.DeploymentTarget is DockerComposeServiceResource serviceResource) { - continue; - } - - var composeServiceContext = await ProcessResourceAsync(resource).ConfigureAwait(false); - - var composeService = await composeServiceContext.BuildComposeServiceAsync(cancellationToken).ConfigureAwait(false); + var composeServiceContext = new ComposeServiceContext(environment, serviceResource, this); + var composeService = await composeServiceContext.BuildComposeServiceAsync(cancellationToken).ConfigureAwait(false); - HandleComposeFileVolumes(composeServiceContext, composeFile); + HandleComposeFileVolumes(serviceResource, composeFile); - composeService.Networks = - [ - defaultNetwork.Name, - ]; + composeService.Networks = + [ + defaultNetwork.Name, + ]; - composeFile.AddService(composeService); + composeFile.AddService(composeService); + } } var composeOutput = composeFile.ToYaml(); @@ -109,7 +108,7 @@ private async Task WriteDockerComposeOutputAsync(DistributedApplicationModel mod Directory.CreateDirectory(PublisherOptions.OutputPath!); await File.WriteAllTextAsync(outputFile, composeOutput, cancellationToken).ConfigureAwait(false); - if (_env.Count == 0) + if (environment.CapturedEnvironmentVariables.Count == 0) { // No environment variables to write, so we can skip creating the .env file return; @@ -120,7 +119,7 @@ private async Task WriteDockerComposeOutputAsync(DistributedApplicationModel mod var envFile = Path.Combine(PublisherOptions.OutputPath!, ".env"); using var envWriter = new StreamWriter(envFile); - foreach (var entry in _env) + foreach (var entry in environment.CapturedEnvironmentVariables ?? []) { var (key, (description, defaultValue)) = entry; @@ -141,20 +140,9 @@ private async Task WriteDockerComposeOutputAsync(DistributedApplicationModel mod await envWriter.FlushAsync().ConfigureAwait(false); } - private async Task 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) + private static void HandleComposeFileVolumes(DockerComposeServiceResource serviceResource, ComposeFile composeFile) { - foreach (var volume in composeServiceContext.Volumes.Where(volume => volume.Type != "bind")) + foreach (var volume in serviceResource.Volumes.Where(volume => volume.Type != "bind")) { if (composeFile.Volumes.ContainsKey(volume.Name)) { @@ -172,7 +160,7 @@ private static void HandleComposeFileVolumes(ComposeServiceContext composeServic } } - private sealed class ComposeServiceContext(IResource resource, DockerComposePublishingContext composePublishingContext) + private sealed class ComposeServiceContext(DockerComposeEnvironmentResource environment, DockerComposeServiceResource resource, DockerComposePublishingContext composePublishingContext) { /// /// Most common shell executables used as container entrypoints in Linux containers. @@ -190,22 +178,16 @@ private sealed class ComposeServiceContext(IResource resource, DockerComposePubl "/usr/bin/bash" }; - private record struct EndpointMapping(string Scheme, string Host, int InternalPort, int ExposedPort, bool IsHttpIngress); - - private readonly Dictionary _endpointMapping = []; - public Dictionary EnvironmentVariables { get; } = []; - private List Commands { get; } = []; - public List Volumes { get; } = []; public bool IsShellExec { get; private set; } public async Task BuildComposeServiceAsync(CancellationToken cancellationToken) { if (composePublishingContext.PublisherOptions.BuildImages) { - await composePublishingContext.ImageBuilder.BuildImageAsync(resource, cancellationToken).ConfigureAwait(false); + await composePublishingContext.ImageBuilder.BuildImageAsync(resource.TargetResource, cancellationToken).ConfigureAwait(false); } - if (!TryGetContainerImageName(resource, out var containerImageName)) + if (!TryGetContainerImageName(resource.TargetResource, out var containerImageName)) { composePublishingContext.Logger.FailedToGetContainerImage(resource.Name); } @@ -227,7 +209,7 @@ public async Task BuildComposeServiceAsync(CancellationToken cancellati private void SetDependsOn(Service composeService) { - if (resource.TryGetAnnotationsOfType(out var waitAnnotations)) + if (resource.TargetResource.TryGetAnnotationsOfType(out var waitAnnotations)) { foreach (var waitAnnotation in waitAnnotations) { @@ -259,9 +241,7 @@ private bool TryGetContainerImageName(IResource resourceInstance, out string? co { var imageEnvName = $"{resourceInstance.Name.ToUpperInvariant().Replace("-", "_")}_IMAGE"; - composePublishingContext.AddEnv(imageEnvName, - $"Container image name for {resourceInstance.Name}", - $"{resourceInstance.Name}:latest"); + environment.CapturedEnvironmentVariables.Add(imageEnvName, ($"Container image name for {resourceInstance.Name}", $"{resourceInstance.Name}:latest")); containerImageName = $"${{{imageEnvName}}}"; return true; @@ -272,12 +252,12 @@ private bool TryGetContainerImageName(IResource resourceInstance, out string? co private void AddVolumes(Service composeService) { - if (Volumes.Count == 0) + if (resource.Volumes.Count == 0) { return; } - foreach (var volume in Volumes) + foreach (var volume in resource.Volumes) { composeService.AddVolume(volume); } @@ -285,12 +265,12 @@ private void AddVolumes(Service composeService) private void AddPorts(Service composeService) { - if (_endpointMapping.Count == 0) + if (resource.EndpointMappings.Count == 0) { return; } - foreach (var (_, mapping) in _endpointMapping) + foreach (var (_, mapping) in resource.EndpointMappings) { var internalPort = mapping.InternalPort.ToString(CultureInfo.InvariantCulture); var exposedPort = mapping.ExposedPort.ToString(CultureInfo.InvariantCulture); @@ -307,270 +287,9 @@ private static void SetContainerImage(string? containerImageName, Service compos } } - 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); - } - } - - private async Task ProcessArgumentsAsync(CancellationToken cancellationToken) - { - if (resource.TryGetAnnotationsOfType(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(out var environmentCallbacks)) - { - var context = new EnvironmentCallbackContext(executionContext, resource, cancellationToken: cancellationToken); - - foreach (var c in environmentCallbacks) - { - await c.Callback(context).ConfigureAwait(false); - } - - RemoveHttpsServiceDiscoveryVariables(context.EnvironmentVariables); - - foreach (var kv in context.EnvironmentVariables) - { - var value = await ProcessValueAsync(kv.Value).ConfigureAwait(false); - - EnvironmentVariables[kv.Key] = value.ToString() ?? string.Empty; - } - } - } - - private static void RemoveHttpsServiceDiscoveryVariables(Dictionary environmentVariables) - { - // HACK: At the moment Docker Compose doesn't do anything with setting up certificates so - // we need to remove the https service discovery variables. - - var keysToRemove = environmentVariables - .Where(kvp => kvp.Value is EndpointReference epRef && epRef.Scheme == "https" && kvp.Key.StartsWith("services__")) - .Select(kvp => kvp.Key) - .ToList(); - - foreach (var key in keysToRemove) - { - environmentVariables.Remove(key); - } - } - - 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, _, 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 => $"{internalPort}", - EndpointProperty.Scheme => scheme, - _ => throw new NotSupportedException(), - }; - - string GetHostValue(string? prefix = null, string? suffix = null) - { - return $"{prefix}{host}{suffix}"; - } - } - - private async Task 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 }) + if (resource.TargetResource is ContainerResource { Entrypoint: { } entrypoint }) { composeService.Entrypoint.Add(entrypoint); @@ -583,20 +302,20 @@ private void SetEntryPoint(Service composeService) private void AddEnvironmentVariablesAndCommandLineArgs(Service composeService) { - if (EnvironmentVariables.Count > 0) + if (resource.EnvironmentVariables.Count > 0) { - foreach (var variable in EnvironmentVariables) + foreach (var variable in resource.EnvironmentVariables) { composeService.AddEnvironmentalVariable(variable.Key, variable.Value); } } - if (Commands.Count > 0) + if (resource.Commands.Count > 0) { if (IsShellExec) { var sb = new StringBuilder(); - foreach (var command in Commands) + foreach (var command in resource.Commands) { // Escape any environment variables expressions in the command // to prevent them from being interpreted by the docker compose CLI @@ -607,7 +326,7 @@ private void AddEnvironmentVariablesAndCommandLineArgs(Service composeService) } else { - composeService.Command.AddRange(Commands); + composeService.Command.AddRange(resource.Commands); } } } diff --git a/src/Aspire.Hosting.Docker/DockerComposeServiceResource.cs b/src/Aspire.Hosting.Docker/DockerComposeServiceResource.cs new file mode 100644 index 00000000000..c92fa2cdddd --- /dev/null +++ b/src/Aspire.Hosting.Docker/DockerComposeServiceResource.cs @@ -0,0 +1,41 @@ +// 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 CS1591 // Missing XML comment for publicly visible type or member + +using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.Docker.Resources.ServiceNodes; + +namespace Aspire.Hosting.Docker; + +/// +/// Represents a compute resource for Docker Compose with strongly-typed properties. +/// +public class DockerComposeServiceResource(string name, IResource resource) : Resource(name) +{ + internal record struct EndpointMapping(string Scheme, string Host, int InternalPort, int ExposedPort, bool IsHttpIngress); + + /// + /// Gets the resource that is the target of this Docker Compose service. + /// + internal IResource TargetResource => resource; + + /// + /// Gets the collection of environment variables for the Docker Compose service. + /// + internal Dictionary EnvironmentVariables { get; } = []; + + /// + /// Gets the collection of commands to be executed by the Docker Compose service. + /// + internal List Commands { get; } = []; + + /// + /// Gets the collection of volumes for the Docker Compose service. + /// + internal List Volumes { get; } = []; + + /// + /// Gets the mapping of endpoint names to their configurations. + /// + internal Dictionary EndpointMappings { get; } = []; +} diff --git a/tests/Aspire.Hosting.Docker.Tests/DockerComposePublisherTests.cs b/tests/Aspire.Hosting.Docker.Tests/DockerComposePublisherTests.cs index 10946c07db0..51107685a86 100644 --- a/tests/Aspire.Hosting.Docker.Tests/DockerComposePublisherTests.cs +++ b/tests/Aspire.Hosting.Docker.Tests/DockerComposePublisherTests.cs @@ -24,6 +24,8 @@ public async Task PublishAsync_GeneratesValidDockerComposeFile() var options = new OptionsMonitor(new DockerComposePublisherOptions { OutputPath = tempDir.Path }); var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + builder.AddDockerComposeEnvironment("docker-compose"); + var param0 = builder.AddParameter("param0"); var param1 = builder.AddParameter("param1", secret: true); var param2 = builder.AddParameter("param2", "default", publishValueAsDefault: true); @@ -161,6 +163,7 @@ public async Task DockerComposeCorrectlyEmitsPortMappings() using var builder = TestDistributedApplicationBuilder.Create(["--operation", "publish", "--publisher", "docker-compose", "--output-path", tempDir.Path]) .WithTestAndResourceLogging(outputHelper); + builder.AddDockerComposeEnvironment("docker-compose"); builder.AddDockerComposePublisher(); builder.AddContainer("resource", "mcr.microsoft.com/dotnet/aspnet:8.0") @@ -205,6 +208,8 @@ public async Task DockerComposeHandleImageBuilding(bool shouldBuildImages) using var builder = TestDistributedApplicationBuilder.Create(["--operation", "publish", "--publisher", "docker-compose", "--output-path", tempDir.Path]) .WithTestAndResourceLogging(outputHelper); + builder.AddDockerComposeEnvironment("docker-compose"); + var options = new OptionsMonitor(new DockerComposePublisherOptions { OutputPath = tempDir.Path, From 79498f34aea34b200c2f47fa2415c45a4fce0afc Mon Sep 17 00:00:00 2001 From: Safia Abdalla Date: Wed, 16 Apr 2025 15:25:59 -0700 Subject: [PATCH 2/3] Shift ComposeService construction into DockerComposeServiceResource --- .../DockerComposeEnvironmentExtensions.cs | 1 + .../DockerComposeInfrastructure.cs | 2 +- .../DockerComposePublishingContext.cs | 184 +----------------- .../DockerComposeServiceResource.cs | 171 +++++++++++++++- ...DockerComposeServiceResourceExtensions.cs} | 2 +- 5 files changed, 179 insertions(+), 181 deletions(-) rename src/Aspire.Hosting.Docker/{CommandLineArgsExtensions.cs => DockerComposeServiceResourceExtensions.cs} (99%) diff --git a/src/Aspire.Hosting.Docker/DockerComposeEnvironmentExtensions.cs b/src/Aspire.Hosting.Docker/DockerComposeEnvironmentExtensions.cs index 404c4e93b39..c94d10da5a4 100644 --- a/src/Aspire.Hosting.Docker/DockerComposeEnvironmentExtensions.cs +++ b/src/Aspire.Hosting.Docker/DockerComposeEnvironmentExtensions.cs @@ -27,6 +27,7 @@ public static IResourceBuilder AddDockerCompos var resource = new DockerComposeEnvironmentResource(name); builder.Services.TryAddLifecycleHook(); + builder.AddDockerComposePublisher(name); return builder.AddResource(resource); } } diff --git a/src/Aspire.Hosting.Docker/DockerComposeInfrastructure.cs b/src/Aspire.Hosting.Docker/DockerComposeInfrastructure.cs index c4f1e0b0154..ea40667ace2 100644 --- a/src/Aspire.Hosting.Docker/DockerComposeInfrastructure.cs +++ b/src/Aspire.Hosting.Docker/DockerComposeInfrastructure.cs @@ -75,7 +75,7 @@ public async Task CreateDockerComposeServiceResour logger.LogInformation("Creating Docker Compose resource for {ResourceName}", resource.Name); - var serviceResource = new DockerComposeServiceResource(resource.Name, resource); + var serviceResource = new DockerComposeServiceResource(resource.Name, resource, environment); _resourceMapping[resource] = serviceResource; // Process endpoints diff --git a/src/Aspire.Hosting.Docker/DockerComposePublishingContext.cs b/src/Aspire.Hosting.Docker/DockerComposePublishingContext.cs index 81e9e5348c1..090de1fd9ab 100644 --- a/src/Aspire.Hosting.Docker/DockerComposePublishingContext.cs +++ b/src/Aspire.Hosting.Docker/DockerComposePublishingContext.cs @@ -3,8 +3,6 @@ #pragma warning disable ASPIREPUBLISHERS001 -using System.Globalization; -using System.Text; using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.Docker.Resources; using Aspire.Hosting.Docker.Resources.ComposeNodes; @@ -32,8 +30,6 @@ internal sealed class DockerComposePublishingContext( public readonly IResourceContainerImageBuilder ImageBuilder = imageBuilder; public readonly DockerComposePublisherOptions PublisherOptions = publisherOptions; - private ILogger Logger => logger; - internal async Task WriteModelAsync(DistributedApplicationModel model) { if (!executionContext.IsPublishMode) @@ -89,8 +85,12 @@ private async Task WriteDockerComposeOutputAsync(DistributedApplicationModel mod if (resource.TryGetLastAnnotation(out var deploymentTargetAnnotation) && deploymentTargetAnnotation.DeploymentTarget is DockerComposeServiceResource serviceResource) { - var composeServiceContext = new ComposeServiceContext(environment, serviceResource, this); - var composeService = await composeServiceContext.BuildComposeServiceAsync(cancellationToken).ConfigureAwait(false); + if (PublisherOptions.BuildImages) + { + await ImageBuilder.BuildImageAsync(serviceResource.TargetResource, cancellationToken).ConfigureAwait(false); + } + + var composeService = serviceResource.ComposeService; HandleComposeFileVolumes(serviceResource, composeFile); @@ -159,176 +159,4 @@ private static void HandleComposeFileVolumes(DockerComposeServiceResource servic composeFile.AddVolume(newVolume); } } - - private sealed class ComposeServiceContext(DockerComposeEnvironmentResource environment, DockerComposeServiceResource resource, DockerComposePublishingContext composePublishingContext) - { - /// - /// 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. - /// - private static readonly HashSet s_shellExecutables = new(StringComparer.OrdinalIgnoreCase) - { - "/bin/sh", - "/bin/bash", - "/sh", - "/bash", - "sh", - "bash", - "/usr/bin/sh", - "/usr/bin/bash" - }; - - public bool IsShellExec { get; private set; } - - public async Task BuildComposeServiceAsync(CancellationToken cancellationToken) - { - if (composePublishingContext.PublisherOptions.BuildImages) - { - await composePublishingContext.ImageBuilder.BuildImageAsync(resource.TargetResource, cancellationToken).ConfigureAwait(false); - } - - if (!TryGetContainerImageName(resource.TargetResource, 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); - SetDependsOn(composeService); - - return composeService; - } - - private void SetDependsOn(Service composeService) - { - if (resource.TargetResource.TryGetAnnotationsOfType(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 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(out _) || resourceInstance is ProjectResource) - { - var imageEnvName = $"{resourceInstance.Name.ToUpperInvariant().Replace("-", "_")}_IMAGE"; - - environment.CapturedEnvironmentVariables.Add(imageEnvName, ($"Container image name for {resourceInstance.Name}", $"{resourceInstance.Name}:latest")); - - containerImageName = $"${{{imageEnvName}}}"; - return true; - } - - return resourceInstance.TryGetContainerImageName(out containerImageName); - } - - private void AddVolumes(Service composeService) - { - if (resource.Volumes.Count == 0) - { - return; - } - - foreach (var volume in resource.Volumes) - { - composeService.AddVolume(volume); - } - } - - private void AddPorts(Service composeService) - { - if (resource.EndpointMappings.Count == 0) - { - return; - } - - foreach (var (_, mapping) in resource.EndpointMappings) - { - 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 void SetEntryPoint(Service composeService) - { - if (resource.TargetResource is ContainerResource { Entrypoint: { } entrypoint }) - { - composeService.Entrypoint.Add(entrypoint); - - if (s_shellExecutables.Contains(entrypoint)) - { - IsShellExec = true; - } - } - } - - private void AddEnvironmentVariablesAndCommandLineArgs(Service composeService) - { - if (resource.EnvironmentVariables.Count > 0) - { - foreach (var variable in resource.EnvironmentVariables) - { - composeService.AddEnvironmentalVariable(variable.Key, variable.Value); - } - } - - if (resource.Commands.Count > 0) - { - if (IsShellExec) - { - var sb = new StringBuilder(); - foreach (var command in resource.Commands) - { - // 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(resource.Commands); - } - } - } - } } diff --git a/src/Aspire.Hosting.Docker/DockerComposeServiceResource.cs b/src/Aspire.Hosting.Docker/DockerComposeServiceResource.cs index c92fa2cdddd..cd358c6a5df 100644 --- a/src/Aspire.Hosting.Docker/DockerComposeServiceResource.cs +++ b/src/Aspire.Hosting.Docker/DockerComposeServiceResource.cs @@ -2,7 +2,10 @@ // The .NET Foundation licenses this file to you under the MIT license. #pragma warning disable CS1591 // Missing XML comment for publicly visible type or member +using System.Globalization; +using System.Text; using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.Docker.Resources.ComposeNodes; using Aspire.Hosting.Docker.Resources.ServiceNodes; namespace Aspire.Hosting.Docker; @@ -10,8 +13,26 @@ namespace Aspire.Hosting.Docker; /// /// Represents a compute resource for Docker Compose with strongly-typed properties. /// -public class DockerComposeServiceResource(string name, IResource resource) : Resource(name) +public class DockerComposeServiceResource(string name, IResource resource, DockerComposeEnvironmentResource composeEnvironmentResource) : Resource(name), IResourceWithParent { + /// + /// 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. + /// + private static readonly HashSet 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(string Scheme, string Host, int InternalPort, int ExposedPort, bool IsHttpIngress); /// @@ -38,4 +59,152 @@ internal record struct EndpointMapping(string Scheme, string Host, int InternalP /// Gets the mapping of endpoint names to their configurations. /// internal Dictionary EndpointMappings { get; } = []; + + public Service ComposeService => GetComposeService(); + + public DockerComposeEnvironmentResource Parent => composeEnvironmentResource; + + private Service GetComposeService() + { + var composeService = new Service + { + Name = resource.Name.ToLowerInvariant(), + }; + + if (TryGetContainerImageName(TargetResource, out var containerImageName)) + { + SetContainerImage(containerImageName, 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(out _) || resourceInstance is ProjectResource) + { + var imageEnvName = $"{resourceInstance.Name.ToUpperInvariant().Replace("-", "_")}_IMAGE"; + + composeEnvironmentResource.CapturedEnvironmentVariables.Add(imageEnvName, ($"Container image name for {resourceInstance.Name}", $"{resourceInstance.Name}:latest")); + + containerImageName = $"${{{imageEnvName}}}"; + return true; + } + + return resourceInstance.TryGetContainerImageName(out containerImageName); + } + + 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(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) + { + if (EnvironmentVariables.Count > 0) + { + foreach (var variable in EnvironmentVariables) + { + composeService.AddEnvironmentalVariable(variable.Key, variable.Value); + } + } + + if (Commands.Count > 0) + { + if (IsShellExec) + { + var sb = new StringBuilder(); + foreach (var command in Commands) + { + // 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(Commands); + } + } + } + + private void AddPorts(Service composeService) + { + if (EndpointMappings.Count == 0) + { + return; + } + + foreach (var (_, mapping) in EndpointMappings) + { + var internalPort = mapping.InternalPort.ToString(CultureInfo.InvariantCulture); + var exposedPort = mapping.ExposedPort.ToString(CultureInfo.InvariantCulture); + + composeService.Ports.Add($"{exposedPort}:{internalPort}"); + } + } + + private void AddVolumes(Service composeService) + { + if (Volumes.Count == 0) + { + return; + } + + foreach (var volume in Volumes) + { + composeService.AddVolume(volume); + } + } } diff --git a/src/Aspire.Hosting.Docker/CommandLineArgsExtensions.cs b/src/Aspire.Hosting.Docker/DockerComposeServiceResourceExtensions.cs similarity index 99% rename from src/Aspire.Hosting.Docker/CommandLineArgsExtensions.cs rename to src/Aspire.Hosting.Docker/DockerComposeServiceResourceExtensions.cs index 8f367b0cd05..96e29879dd7 100644 --- a/src/Aspire.Hosting.Docker/CommandLineArgsExtensions.cs +++ b/src/Aspire.Hosting.Docker/DockerComposeServiceResourceExtensions.cs @@ -6,7 +6,7 @@ using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.Docker; -internal static class CommandLineArgsExtensions +internal static class DockerComposeServiceResourceExtensions { internal static async Task ProcessValueAsync(this DockerComposeServiceResource resource, DockerComposeInfrastructure.DockerComposeEnvironmentContext context, DistributedApplicationExecutionContext executionContext, object value) { From 3ee6962839bd1ea99f0fb3c98596e1b1a26b04b0 Mon Sep 17 00:00:00 2001 From: Safia Abdalla Date: Wed, 16 Apr 2025 16:31:28 -0700 Subject: [PATCH 3/3] Support customization via PublishAsDockerComposeService --- ...erComposeServiceCustomizationAnnotation.cs | 23 +++++++ .../DockerComposeServiceExtensions.cs | 49 ++++++++++++++ .../DockerComposeServiceResource.cs | 9 +++ .../DockerComposePublisherTests.cs | 67 +++++++++++++++++++ 4 files changed, 148 insertions(+) create mode 100644 src/Aspire.Hosting.Docker/DockerComposeServiceCustomizationAnnotation.cs create mode 100644 src/Aspire.Hosting.Docker/DockerComposeServiceExtensions.cs diff --git a/src/Aspire.Hosting.Docker/DockerComposeServiceCustomizationAnnotation.cs b/src/Aspire.Hosting.Docker/DockerComposeServiceCustomizationAnnotation.cs new file mode 100644 index 00000000000..3a3e8c5a393 --- /dev/null +++ b/src/Aspire.Hosting.Docker/DockerComposeServiceCustomizationAnnotation.cs @@ -0,0 +1,23 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.Docker.Resources.ComposeNodes; + +namespace Aspire.Hosting.Docker; + +/// +/// Represents an annotation for customizing a Docker Compose service. +/// +/// +/// Initializes a new instance of the class. +/// +/// The configuration action for customizing the Docker Compose service. +public sealed class DockerComposeServiceCustomizationAnnotation(Action configure) : IResourceAnnotation +{ + + /// + /// Gets the configuration action for customizing the Docker Compose service. + /// + public Action Configure { get; } = configure ?? throw new ArgumentNullException(nameof(configure)); +} diff --git a/src/Aspire.Hosting.Docker/DockerComposeServiceExtensions.cs b/src/Aspire.Hosting.Docker/DockerComposeServiceExtensions.cs new file mode 100644 index 00000000000..bf91e4904c4 --- /dev/null +++ b/src/Aspire.Hosting.Docker/DockerComposeServiceExtensions.cs @@ -0,0 +1,49 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.Docker; +using Aspire.Hosting.Docker.Resources.ComposeNodes; + +namespace Aspire.Hosting; + +/// +/// Provides extension methods for customizing Docker Compose service resources. +/// +public static class DockerComposeServiceExtensions +{ + /// + /// Publishes the specified resource as a Docker Compose service. + /// + /// The type of the resource. + /// The resource builder. + /// The configuration action for the Docker Compose service. + /// The updated resource builder. + /// + /// This method checks if the application is in publish mode. If it is, it adds a customization annotation + /// that will be applied by the DockerComposeInfrastructure when generating the Docker Compose service. + /// + /// + /// + /// builder.AddContainer("redis", "redis:alpine").PublishAsDockerComposeService((resource, service) => + /// { + /// service.Name = "redis"; + /// }); + /// + /// + public static IResourceBuilder PublishAsDockerComposeService(this IResourceBuilder builder, Action configure) + where T : IResource + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(configure); + + if (!builder.ApplicationBuilder.ExecutionContext.IsPublishMode) + { + return builder; + } + + builder.WithAnnotation(new DockerComposeServiceCustomizationAnnotation(configure)); + + return builder; + } +} diff --git a/src/Aspire.Hosting.Docker/DockerComposeServiceResource.cs b/src/Aspire.Hosting.Docker/DockerComposeServiceResource.cs index cd358c6a5df..43cf31d2962 100644 --- a/src/Aspire.Hosting.Docker/DockerComposeServiceResource.cs +++ b/src/Aspire.Hosting.Docker/DockerComposeServiceResource.cs @@ -81,6 +81,15 @@ private Service GetComposeService() AddPorts(composeService); AddVolumes(composeService); SetDependsOn(composeService); + + if (resource.TryGetAnnotationsOfType(out var annotations)) + { + foreach (var a in annotations) + { + a.Configure(this, composeService); + } + } + return composeService; } diff --git a/tests/Aspire.Hosting.Docker.Tests/DockerComposePublisherTests.cs b/tests/Aspire.Hosting.Docker.Tests/DockerComposePublisherTests.cs index 51107685a86..60eefc3ad5d 100644 --- a/tests/Aspire.Hosting.Docker.Tests/DockerComposePublisherTests.cs +++ b/tests/Aspire.Hosting.Docker.Tests/DockerComposePublisherTests.cs @@ -245,6 +245,73 @@ public async Task DockerComposeHandleImageBuilding(bool shouldBuildImages) Assert.Equal(shouldBuildImages, mockImageBuilder.BuildImageCalled); } + [Fact] + public async Task DockerComposeAppliesServiceCustomizations() + { + using var tempDir = new TempDirectory(); + var options = new OptionsMonitor(new DockerComposePublisherOptions { OutputPath = tempDir.Path }); + var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + + builder.AddDockerComposeEnvironment("docker-compose"); + + // Add a container to the application + var container = builder.AddContainer("service", "nginx") + .WithEnvironment("ORIGINAL_ENV", "value") + .PublishAsDockerComposeService((serviceResource, composeService) => + { + // Add a custom label + composeService.Labels["custom-label"] = "test-value"; + + // Add a custom environment variable + composeService.AddEnvironmentalVariable("CUSTOM_ENV", "custom-value"); + + // Set a restart policy + composeService.Restart = "always"; + }); + + var app = builder.Build(); + + var model = app.Services.GetRequiredService(); + + await ExecuteBeforeStartHooksAsync(app, default); + + var publisher = new DockerComposePublisher("test", + options, + NullLogger.Instance, + builder.ExecutionContext, + new MockImageBuilder() + ); + + // Act + await publisher.PublishAsync(model, default); + + // Assert + var composePath = Path.Combine(tempDir.Path, "docker-compose.yaml"); + Assert.True(File.Exists(composePath)); + + var content = await File.ReadAllTextAsync(composePath); + + Assert.Equal( + """ + services: + service: + image: "nginx:latest" + environment: + ORIGINAL_ENV: "value" + CUSTOM_ENV: "custom-value" + networks: + - "aspire" + restart: "always" + labels: + custom-label: "test-value" + networks: + aspire: + driver: "bridge" + + """, + content, ignoreAllWhiteSpace: true, ignoreLineEndingDifferences: true); + } + [UnsafeAccessor(UnsafeAccessorKind.Method, Name = "ExecuteBeforeStartHooksAsync")] private static extern Task ExecuteBeforeStartHooksAsync(DistributedApplication app, CancellationToken cancellationToken);