Skip to content

Commit 1db6fb2

Browse files
authored
Model Docker Compose as compute environment (#8828)
* Model Docker Compose as compute environment * Shift ComposeService construction into DockerComposeServiceResource * Support customization via PublishAsDockerComposeService
1 parent bd6da41 commit 1db6fb2

9 files changed

+803
-486
lines changed
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using Aspire.Hosting.ApplicationModel;
5+
using Aspire.Hosting.Docker;
6+
using Aspire.Hosting.Lifecycle;
7+
8+
namespace Aspire.Hosting;
9+
10+
/// <summary>
11+
/// Provides extension methods for adding Docker Compose environment resources to the application model.
12+
/// </summary>
13+
public static class DockerComposeEnvironmentExtensions
14+
{
15+
/// <summary>
16+
/// Adds a Docker Compose environment to the application model.
17+
/// </summary>
18+
/// <param name="builder">The <see cref="IDistributedApplicationBuilder"/>.</param>
19+
/// <param name="name">The name of the Docker Compose environment resource.</param>
20+
/// <returns>A reference to the <see cref="IResourceBuilder{DockerComposeEnvironmentResource}"/>.</returns>
21+
public static IResourceBuilder<DockerComposeEnvironmentResource> AddDockerComposeEnvironment(
22+
this IDistributedApplicationBuilder builder,
23+
string name)
24+
{
25+
ArgumentNullException.ThrowIfNull(builder);
26+
ArgumentException.ThrowIfNullOrEmpty(name);
27+
28+
var resource = new DockerComposeEnvironmentResource(name);
29+
builder.Services.TryAddLifecycleHook<DockerComposeInfrastructure>();
30+
builder.AddDockerComposePublisher(name);
31+
return builder.AddResource(resource);
32+
}
33+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using Aspire.Hosting.ApplicationModel;
5+
6+
namespace Aspire.Hosting.Docker;
7+
8+
/// <summary>
9+
/// Represents a Docker Compose environment resource that can host application resources.
10+
/// </summary>
11+
/// <remarks>
12+
/// Initializes a new instance of the <see cref="DockerComposeEnvironmentResource"/> class.
13+
/// </remarks>
14+
/// <param name="name">The name of the Docker Compose environment.</param>
15+
public class DockerComposeEnvironmentResource(string name) : Resource(name)
16+
{
17+
/// <summary>
18+
/// Gets the collection of environment variables captured from the Docker Compose environment.
19+
/// These will be populated into a top-level .env file adjacent to the Docker Compose file.
20+
/// </summary>
21+
internal Dictionary<string, (string Description, string? DefaultValue)> CapturedEnvironmentVariables { get; } = [];
22+
}
Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using Aspire.Hosting.ApplicationModel;
5+
using Aspire.Hosting.Lifecycle;
6+
using Aspire.Hosting.Publishing;
7+
using Microsoft.Extensions.Logging;
8+
9+
namespace Aspire.Hosting.Docker;
10+
11+
/// <summary>
12+
/// Represents the infrastructure for Docker Compose within the Aspire Hosting environment.
13+
/// Implements the <see cref="IDistributedApplicationLifecycleHook"/> interface to provide lifecycle hooks for distributed applications.
14+
/// </summary>
15+
internal sealed class DockerComposeInfrastructure(
16+
ILogger<DockerComposeInfrastructure> logger,
17+
DistributedApplicationExecutionContext executionContext) : IDistributedApplicationLifecycleHook
18+
{
19+
public async Task BeforeStartAsync(DistributedApplicationModel appModel, CancellationToken cancellationToken = default)
20+
{
21+
if (executionContext.IsRunMode)
22+
{
23+
return;
24+
}
25+
26+
// Find Docker Compose environment resources
27+
var dockerComposeEnvironments = appModel.Resources.OfType<DockerComposeEnvironmentResource>().ToArray();
28+
29+
if (dockerComposeEnvironments.Length > 1)
30+
{
31+
throw new NotSupportedException("Multiple Docker Compose environments are not supported.");
32+
}
33+
34+
var environment = dockerComposeEnvironments.FirstOrDefault();
35+
36+
if (environment == null)
37+
{
38+
return;
39+
}
40+
41+
var dockerComposeEnvironmentContext = new DockerComposeEnvironmentContext(environment, logger);
42+
43+
foreach (var r in appModel.Resources)
44+
{
45+
if (r.TryGetLastAnnotation<ManifestPublishingCallbackAnnotation>(out var lastAnnotation) && lastAnnotation == ManifestPublishingCallbackAnnotation.Ignore)
46+
{
47+
continue;
48+
}
49+
50+
// Skip resources that are not containers or projects
51+
if (!r.IsContainer() && r is not ProjectResource)
52+
{
53+
continue;
54+
}
55+
56+
// Create a Docker Compose compute resource for the resource
57+
var serviceResource = await dockerComposeEnvironmentContext.CreateDockerComposeServiceResourceAsync(r, executionContext, cancellationToken).ConfigureAwait(false);
58+
59+
// Add deployment target annotation to the resource
60+
r.Annotations.Add(new DeploymentTargetAnnotation(serviceResource));
61+
}
62+
}
63+
64+
internal sealed class DockerComposeEnvironmentContext(DockerComposeEnvironmentResource environment, ILogger logger)
65+
{
66+
private readonly Dictionary<IResource, DockerComposeServiceResource> _resourceMapping = [];
67+
private readonly PortAllocator _portAllocator = new();
68+
69+
public async Task<DockerComposeServiceResource> CreateDockerComposeServiceResourceAsync(IResource resource, DistributedApplicationExecutionContext executionContext, CancellationToken cancellationToken)
70+
{
71+
if (_resourceMapping.TryGetValue(resource, out var existingResource))
72+
{
73+
return existingResource;
74+
}
75+
76+
logger.LogInformation("Creating Docker Compose resource for {ResourceName}", resource.Name);
77+
78+
var serviceResource = new DockerComposeServiceResource(resource.Name, resource, environment);
79+
_resourceMapping[resource] = serviceResource;
80+
81+
// Process endpoints
82+
ProcessEndpoints(serviceResource);
83+
84+
// Process volumes
85+
ProcessVolumes(serviceResource);
86+
87+
// Process environment variables
88+
await ProcessEnvironmentVariablesAsync(serviceResource, executionContext, cancellationToken).ConfigureAwait(false);
89+
90+
// Process command line arguments
91+
await ProcessArgumentsAsync(serviceResource, executionContext, cancellationToken).ConfigureAwait(false);
92+
93+
return serviceResource;
94+
}
95+
96+
private void ProcessEndpoints(DockerComposeServiceResource serviceResource)
97+
{
98+
if (!serviceResource.TargetResource.TryGetEndpoints(out var endpoints))
99+
{
100+
return;
101+
}
102+
103+
foreach (var endpoint in endpoints)
104+
{
105+
var internalPort = endpoint.TargetPort ?? _portAllocator.AllocatePort();
106+
_portAllocator.AddUsedPort(internalPort);
107+
108+
var exposedPort = _portAllocator.AllocatePort();
109+
_portAllocator.AddUsedPort(exposedPort);
110+
111+
serviceResource.EndpointMappings.Add(endpoint.Name, new(endpoint.UriScheme, serviceResource.TargetResource.Name, internalPort, exposedPort, false));
112+
}
113+
}
114+
115+
private static void ProcessVolumes(DockerComposeServiceResource serviceResource)
116+
{
117+
if (!serviceResource.TargetResource.TryGetContainerMounts(out var mounts))
118+
{
119+
return;
120+
}
121+
122+
foreach (var mount in mounts)
123+
{
124+
if (mount.Source is null || mount.Target is null)
125+
{
126+
throw new InvalidOperationException("Volume source and target must be set");
127+
}
128+
129+
serviceResource.Volumes.Add(new Resources.ServiceNodes.Volume
130+
{
131+
Name = mount.Source,
132+
Source = mount.Source,
133+
Target = mount.Target,
134+
Type = mount.Type == ContainerMountType.BindMount ? "bind" : "volume",
135+
ReadOnly = mount.IsReadOnly
136+
});
137+
}
138+
}
139+
140+
private async Task ProcessEnvironmentVariablesAsync(DockerComposeServiceResource serviceResource, DistributedApplicationExecutionContext executionContext, CancellationToken cancellationToken)
141+
{
142+
if (serviceResource.TargetResource.TryGetAnnotationsOfType<EnvironmentCallbackAnnotation>(out var environmentCallbacks))
143+
{
144+
var context = new EnvironmentCallbackContext(executionContext, serviceResource.TargetResource, cancellationToken: cancellationToken);
145+
146+
foreach (var callback in environmentCallbacks)
147+
{
148+
await callback.Callback(context).ConfigureAwait(false);
149+
}
150+
151+
// Remove HTTPS service discovery variables as Docker Compose doesn't handle certificates
152+
RemoveHttpsServiceDiscoveryVariables(context.EnvironmentVariables);
153+
154+
foreach (var kv in context.EnvironmentVariables)
155+
{
156+
var value = await serviceResource.ProcessValueAsync(this, executionContext, kv.Value).ConfigureAwait(false);
157+
serviceResource.EnvironmentVariables.Add(kv.Key, value?.ToString() ?? string.Empty);
158+
}
159+
}
160+
}
161+
162+
private static void RemoveHttpsServiceDiscoveryVariables(Dictionary<string, object> environmentVariables)
163+
{
164+
var keysToRemove = environmentVariables
165+
.Where(kvp => kvp.Value is EndpointReference epRef && epRef.Scheme == "https" && kvp.Key.StartsWith("services__"))
166+
.Select(kvp => kvp.Key)
167+
.ToList();
168+
169+
foreach (var key in keysToRemove)
170+
{
171+
environmentVariables.Remove(key);
172+
}
173+
}
174+
175+
private async Task ProcessArgumentsAsync(DockerComposeServiceResource serviceResource, DistributedApplicationExecutionContext executionContext, CancellationToken cancellationToken)
176+
{
177+
if (serviceResource.TargetResource.TryGetAnnotationsOfType<CommandLineArgsCallbackAnnotation>(out var commandLineArgsCallbacks))
178+
{
179+
var context = new CommandLineArgsCallbackContext([], cancellationToken: cancellationToken);
180+
181+
foreach (var callback in commandLineArgsCallbacks)
182+
{
183+
await callback.Callback(context).ConfigureAwait(false);
184+
}
185+
186+
foreach (var arg in context.Args)
187+
{
188+
var value = await serviceResource.ProcessValueAsync(this, executionContext, arg).ConfigureAwait(false);
189+
if (value is not string str)
190+
{
191+
throw new NotSupportedException("Command line args must be strings");
192+
}
193+
194+
serviceResource.Commands.Add(str);
195+
}
196+
}
197+
}
198+
199+
public void AddEnv(string name, string description, string? defaultValue = null)
200+
{
201+
environment.CapturedEnvironmentVariables[name] = (description, defaultValue);
202+
}
203+
}
204+
}

0 commit comments

Comments
 (0)