Skip to content
42 changes: 42 additions & 0 deletions src/Aspire.Hosting.Docker/CapturedEnvironmentVariable.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
// 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;

/// <summary>
/// Represents a captured environment variable that will be written to the .env file
/// adjacent to the Docker Compose file.
/// </summary>
public sealed class CapturedEnvironmentVariable
{
/// <summary>
/// Gets the name of the environment variable.
/// </summary>
public required string Name { get; init; }

/// <summary>
/// Gets or sets the description for the environment variable.
/// </summary>
public string? Description { get; set; }
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should all these be { get; init; } instead?


/// <summary>
/// Gets or sets the default value for the environment variable.
/// </summary>
public string? DefaultValue { get; set; }

/// <summary>
/// Gets or sets the source object that originated this environment variable.
/// This could be a <see cref="ParameterResource"/>,
/// <see cref="ContainerMountAnnotation"/>, or other source types.
/// </summary>
public object? Source { get; set; }

/// <summary>
/// Gets or sets the resource that this environment variable is associated with.
/// This is useful when the source is an annotation on a resource, allowing you to
/// identify which resource this environment variable is related to.
/// </summary>
public IResource? Resource { get; set; }
}
47 changes: 44 additions & 3 deletions src/Aspire.Hosting.Docker/DockerComposeEnvironmentContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -65,31 +65,72 @@ private void ProcessEndpoints(DockerComposeServiceResource serviceResource)
}
}

private static void ProcessVolumes(DockerComposeServiceResource serviceResource)
private void ProcessVolumes(DockerComposeServiceResource serviceResource)
{
if (!serviceResource.TargetResource.TryGetContainerMounts(out var mounts))
{
return;
}

var bindMountIndex = 0;

foreach (var mount in mounts)
{
if (mount.Source is null || mount.Target is null)
{
throw new InvalidOperationException("Volume source and target must be set");
}

var source = mount.Source;
var name = mount.Source;

// For bind mounts, create environment placeholders for the source path
// Skip the docker socket which should be left as-is for portability
if (mount.Type == ContainerMountType.BindMount && !IsDockerSocket(mount.Source))
{
// Create environment variable name: {RESOURCE_NAME}_BINDMOUNT_{INDEX}
var envVarName = $"{serviceResource.Name.ToUpperInvariant().Replace("-", "_").Replace(".", "_")}_BINDMOUNT_{bindMountIndex}";
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: Our "map X to env var name" logic is inconsistent and duplicated across a bunch of places. We should consider centralizing it and using the ATPI her.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will do

bindMountIndex++;

// Add the placeholder to captured environment variables so it gets written to the .env file
// Use the original source path as the default value and pass the ContainerMountAnnotation as the source
var placeholder = environment.AddEnvironmentVariable(
envVarName,
description: $"Bind mount source for {serviceResource.Name}:{mount.Target}",
defaultValue: mount.Source,
source: mount,
resource: serviceResource.TargetResource);

// Log warning about host-specific path
logger.BindMountHostSpecificPath(serviceResource.Name, mount.Source, envVarName);

// Use the placeholder in the compose file
source = placeholder;
name = envVarName;
}

serviceResource.Volumes.Add(new Resources.ServiceNodes.Volume
{
Name = mount.Source,
Source = mount.Source,
Name = name,
Source = source,
Target = mount.Target,
Type = mount.Type == ContainerMountType.BindMount ? "bind" : "volume",
ReadOnly = mount.IsReadOnly
});
}
}

/// <summary>
/// Checks if the source path is the Docker socket path.
/// </summary>
private static bool IsDockerSocket(string source)
{
// Check for common Docker socket paths across different platforms
return source.Equals("/var/run/docker.sock", StringComparison.OrdinalIgnoreCase) ||
source.Equals("//var/run/docker.sock", StringComparison.OrdinalIgnoreCase) || // WSL-style path
source.Equals(@"\\.\pipe\docker_engine", StringComparison.OrdinalIgnoreCase); // Windows named pipe
}

private static async Task ProcessEnvironmentVariablesAsync(DockerComposeServiceResource serviceResource, DistributedApplicationExecutionContext executionContext, CancellationToken cancellationToken)
{
if (serviceResource.TargetResource.TryGetAnnotationsOfType<EnvironmentCallbackAnnotation>(out var environmentCallbacks))
Expand Down
19 changes: 19 additions & 0 deletions src/Aspire.Hosting.Docker/DockerComposeEnvironmentExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,25 @@ public static IResourceBuilder<DockerComposeEnvironmentResource> ConfigureCompos
return builder;
}

/// <summary>
/// Configures the captured environment variables for the Docker Compose environment before they are written to the .env file.
/// </summary>
/// <param name="builder">The Docker Compose environment resource builder.</param>
/// <param name="configure">A method that can be used for customizing the captured environment variables.</param>
/// <returns>A reference to the <see cref="IResourceBuilder{T}"/>.</returns>
/// <remarks>
/// This callback is invoked during the prepare phase, allowing programmatic modification of the environment variables
/// that will be written to the .env file adjacent to the Docker Compose file.
/// </remarks>
public static IResourceBuilder<DockerComposeEnvironmentResource> ConfigureEnvironment(this IResourceBuilder<DockerComposeEnvironmentResource> builder, Action<IDictionary<string, CapturedEnvironmentVariable>> configure)
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentNullException.ThrowIfNull(configure);

builder.Resource.ConfigureEnvironment += configure;
return builder;
}

/// <summary>
/// Enables the Aspire dashboard for telemetry visualization in this Docker Compose environment.
/// </summary>
Expand Down
24 changes: 17 additions & 7 deletions src/Aspire.Hosting.Docker/DockerComposeEnvironmentResource.cs
Original file line number Diff line number Diff line change
Expand Up @@ -41,13 +41,15 @@ public class DockerComposeEnvironmentResource : Resource, IComputeEnvironmentRes

internal Action<ComposeFile>? ConfigureComposeFile { get; set; }

internal Action<IDictionary<string, CapturedEnvironmentVariable>>? ConfigureEnvironment { get; set; }

internal IResourceBuilder<DockerComposeAspireDashboardResource>? Dashboard { get; set; }

/// <summary>
/// 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.
/// </summary>
internal Dictionary<string, (string? Description, string? DefaultValue, object? Source)> CapturedEnvironmentVariables { get; } = [];
internal Dictionary<string, CapturedEnvironmentVariable> CapturedEnvironmentVariables { get; } = [];

internal Dictionary<IResource, DockerComposeServiceResource> ResourceMapping { get; } = new(new ResourceNameComparer());

Expand Down Expand Up @@ -342,27 +344,35 @@ private async Task PrepareAsync(PipelineStepContext context)

foreach (var entry in CapturedEnvironmentVariables)
{
var (key, (description, defaultValue, source)) = entry;
var envVar = entry.Value;
var defaultValue = envVar.DefaultValue;

if (defaultValue is null && source is ParameterResource parameter)
if (defaultValue is null && envVar.Source is ParameterResource parameter)
{
defaultValue = await parameter.GetValueAsync(context.CancellationToken).ConfigureAwait(false);
}

if (source is ContainerImageReference cir && cir.Resource.TryGetContainerImageName(out var imageName))
if (envVar.Source is ContainerImageReference cir && cir.Resource.TryGetContainerImageName(out var imageName))
{
defaultValue = imageName;
}

envFile.Add(key, defaultValue, description, onlyIfMissing: false);
envFile.Add(entry.Key, defaultValue, envVar.Description, onlyIfMissing: false);
}

envFile.Save(includeValues: true);
}

internal string AddEnvironmentVariable(string name, string? description = null, string? defaultValue = null, object? source = null)
internal string AddEnvironmentVariable(string name, string? description = null, string? defaultValue = null, object? source = null, IResource? resource = null)
{
CapturedEnvironmentVariables[name] = (description, defaultValue, source);
CapturedEnvironmentVariables[name] = new CapturedEnvironmentVariable
{
Name = name,
Description = description,
DefaultValue = defaultValue,
Source = source,
Resource = resource
};

return $"${{{name}}}";
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,4 +36,7 @@ internal static partial class DockerComposePublisherLoggerExtensions

[LoggerMessage(LogLevel.Error, "Failed to copy referenced file '{FilePath}' to '{OutputPath}'")]
internal static partial void FailedToCopyFile(this ILogger logger, string filePath, string outputPath);

[LoggerMessage(LogLevel.Warning, "Resource '{ResourceName}' has a bind mount with host-specific source path '{SourcePath}'. The source has been replaced with an environment variable placeholder '{Placeholder}' which may make the application less portable across different machines.")]
internal static partial void BindMountHostSpecificPath(this ILogger logger, string resourceName, string sourcePath, string placeholder);
}
9 changes: 6 additions & 3 deletions src/Aspire.Hosting.Docker/DockerComposePublishingContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,9 @@ private async Task WriteDockerComposeOutputAsync(DistributedApplicationModel mod
// Call the environment's ConfigureComposeFile method to allow for custom modifications
environment.ConfigureComposeFile?.Invoke(composeFile);

// Allow mutation of the captured environment variables before writing
environment.ConfigureEnvironment?.Invoke(environment.CapturedEnvironmentVariables);

var composeOutput = composeFile.ToYaml();
var outputFile = Path.Combine(OutputPath, "docker-compose.yaml");
Directory.CreateDirectory(OutputPath);
Expand All @@ -155,11 +158,11 @@ private async Task WriteDockerComposeOutputAsync(DistributedApplicationModel mod
var envFilePath = Path.Combine(OutputPath, ".env");
var envFile = EnvFile.Load(envFilePath, logger);

foreach (var entry in environment.CapturedEnvironmentVariables ?? [])
foreach (var entry in environment.CapturedEnvironmentVariables)
{
var (key, (description, _, _)) = entry;
var envVar = entry.Value;

envFile.Add(key, value: null, description, onlyIfMissing: true);
envFile.Add(entry.Key, value: null, envVar.Description, onlyIfMissing: true);
}

envFile.Save(includeValues: false);
Expand Down
Loading