Skip to content

Commit d65926c

Browse files
authored
Add compute environment annotation to Docker Compose service resources (#8843)
* Add compute environment annotation to Docker Compose service resource and implement tests * Refactor Docker Compose infrastructure by moving DockerComposeEnvironmentContext to a new file and cleaning up unused imports * Refactor DockerComposeServiceResourceExtensions to remove redundant namespace qualification for DockerComposeEnvironmentContext
1 parent 0f87eb4 commit d65926c

File tree

4 files changed

+191
-145
lines changed

4 files changed

+191
-145
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
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.Publishing;
6+
using Microsoft.Extensions.Logging;
7+
8+
namespace Aspire.Hosting.Docker;
9+
10+
internal sealed class DockerComposeEnvironmentContext(DockerComposeEnvironmentResource environment, ILogger logger)
11+
{
12+
private readonly Dictionary<IResource, DockerComposeServiceResource> _resourceMapping = [];
13+
private readonly PortAllocator _portAllocator = new();
14+
15+
public async Task<DockerComposeServiceResource> CreateDockerComposeServiceResourceAsync(IResource resource, DistributedApplicationExecutionContext executionContext, CancellationToken cancellationToken)
16+
{
17+
if (_resourceMapping.TryGetValue(resource, out var existingResource))
18+
{
19+
return existingResource;
20+
}
21+
22+
logger.LogInformation("Creating Docker Compose resource for {ResourceName}", resource.Name);
23+
24+
var serviceResource = new DockerComposeServiceResource(resource.Name, resource, environment);
25+
_resourceMapping[resource] = serviceResource;
26+
27+
// Process endpoints
28+
ProcessEndpoints(serviceResource);
29+
30+
// Process volumes
31+
ProcessVolumes(serviceResource);
32+
33+
// Process environment variables
34+
await ProcessEnvironmentVariablesAsync(serviceResource, executionContext, cancellationToken).ConfigureAwait(false);
35+
36+
// Process command line arguments
37+
await ProcessArgumentsAsync(serviceResource, executionContext, cancellationToken).ConfigureAwait(false);
38+
39+
return serviceResource;
40+
}
41+
42+
private void ProcessEndpoints(DockerComposeServiceResource serviceResource)
43+
{
44+
if (!serviceResource.TargetResource.TryGetEndpoints(out var endpoints))
45+
{
46+
return;
47+
}
48+
49+
foreach (var endpoint in endpoints)
50+
{
51+
var internalPort = endpoint.TargetPort ?? _portAllocator.AllocatePort();
52+
_portAllocator.AddUsedPort(internalPort);
53+
54+
var exposedPort = _portAllocator.AllocatePort();
55+
_portAllocator.AddUsedPort(exposedPort);
56+
57+
serviceResource.EndpointMappings.Add(endpoint.Name, new(endpoint.UriScheme, serviceResource.TargetResource.Name, internalPort, exposedPort, false));
58+
}
59+
}
60+
61+
private static void ProcessVolumes(DockerComposeServiceResource serviceResource)
62+
{
63+
if (!serviceResource.TargetResource.TryGetContainerMounts(out var mounts))
64+
{
65+
return;
66+
}
67+
68+
foreach (var mount in mounts)
69+
{
70+
if (mount.Source is null || mount.Target is null)
71+
{
72+
throw new InvalidOperationException("Volume source and target must be set");
73+
}
74+
75+
serviceResource.Volumes.Add(new Resources.ServiceNodes.Volume
76+
{
77+
Name = mount.Source,
78+
Source = mount.Source,
79+
Target = mount.Target,
80+
Type = mount.Type == ContainerMountType.BindMount ? "bind" : "volume",
81+
ReadOnly = mount.IsReadOnly
82+
});
83+
}
84+
}
85+
86+
private async Task ProcessEnvironmentVariablesAsync(DockerComposeServiceResource serviceResource, DistributedApplicationExecutionContext executionContext, CancellationToken cancellationToken)
87+
{
88+
if (serviceResource.TargetResource.TryGetAnnotationsOfType<EnvironmentCallbackAnnotation>(out var environmentCallbacks))
89+
{
90+
var context = new EnvironmentCallbackContext(executionContext, serviceResource.TargetResource, cancellationToken: cancellationToken);
91+
92+
foreach (var callback in environmentCallbacks)
93+
{
94+
await callback.Callback(context).ConfigureAwait(false);
95+
}
96+
97+
// Remove HTTPS service discovery variables as Docker Compose doesn't handle certificates
98+
RemoveHttpsServiceDiscoveryVariables(context.EnvironmentVariables);
99+
100+
foreach (var kv in context.EnvironmentVariables)
101+
{
102+
var value = await serviceResource.ProcessValueAsync(this, executionContext, kv.Value).ConfigureAwait(false);
103+
serviceResource.EnvironmentVariables.Add(kv.Key, value?.ToString() ?? string.Empty);
104+
}
105+
}
106+
}
107+
108+
private static void RemoveHttpsServiceDiscoveryVariables(Dictionary<string, object> environmentVariables)
109+
{
110+
var keysToRemove = environmentVariables
111+
.Where(kvp => kvp.Value is EndpointReference epRef && epRef.Scheme == "https" && kvp.Key.StartsWith("services__"))
112+
.Select(kvp => kvp.Key)
113+
.ToList();
114+
115+
foreach (var key in keysToRemove)
116+
{
117+
environmentVariables.Remove(key);
118+
}
119+
}
120+
121+
private async Task ProcessArgumentsAsync(DockerComposeServiceResource serviceResource, DistributedApplicationExecutionContext executionContext, CancellationToken cancellationToken)
122+
{
123+
if (serviceResource.TargetResource.TryGetAnnotationsOfType<CommandLineArgsCallbackAnnotation>(out var commandLineArgsCallbacks))
124+
{
125+
var context = new CommandLineArgsCallbackContext([], cancellationToken: cancellationToken);
126+
127+
foreach (var callback in commandLineArgsCallbacks)
128+
{
129+
await callback.Callback(context).ConfigureAwait(false);
130+
}
131+
132+
foreach (var arg in context.Args)
133+
{
134+
var value = await serviceResource.ProcessValueAsync(this, executionContext, arg).ConfigureAwait(false);
135+
if (value is not string str)
136+
{
137+
throw new NotSupportedException("Command line args must be strings");
138+
}
139+
140+
serviceResource.Commands.Add(str);
141+
}
142+
}
143+
}
144+
145+
public void AddEnv(string name, string description, string? defaultValue = null)
146+
{
147+
environment.CapturedEnvironmentVariables[name] = (description, defaultValue);
148+
}
149+
}

src/Aspire.Hosting.Docker/DockerComposeInfrastructure.cs

+5-142
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33

44
using Aspire.Hosting.ApplicationModel;
55
using Aspire.Hosting.Lifecycle;
6-
using Aspire.Hosting.Publishing;
76
using Microsoft.Extensions.Logging;
87

98
namespace Aspire.Hosting.Docker;
@@ -57,148 +56,12 @@ public async Task BeforeStartAsync(DistributedApplicationModel appModel, Cancell
5756
var serviceResource = await dockerComposeEnvironmentContext.CreateDockerComposeServiceResourceAsync(r, executionContext, cancellationToken).ConfigureAwait(false);
5857

5958
// 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))
59+
#pragma warning disable ASPIRECOMPUTE001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
60+
r.Annotations.Add(new DeploymentTargetAnnotation(serviceResource)
14361
{
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);
62+
ComputeEnvironment = environment,
63+
});
64+
#pragma warning restore ASPIRECOMPUTE001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
20265
}
20366
}
20467
}

src/Aspire.Hosting.Docker/DockerComposeServiceResourceExtensions.cs

+3-3
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

99
internal static class DockerComposeServiceResourceExtensions
1010
{
11-
internal static async Task<object> ProcessValueAsync(this DockerComposeServiceResource resource, DockerComposeInfrastructure.DockerComposeEnvironmentContext context, DistributedApplicationExecutionContext executionContext, object value)
11+
internal static async Task<object> ProcessValueAsync(this DockerComposeServiceResource resource, DockerComposeEnvironmentContext context, DistributedApplicationExecutionContext executionContext, object value)
1212
{
1313
while (true)
1414
{
@@ -108,7 +108,7 @@ string GetHostValue(string? prefix = null, string? suffix = null)
108108
}
109109
}
110110

111-
private static string ResolveParameterValue(ParameterResource parameter, DockerComposeInfrastructure.DockerComposeEnvironmentContext context)
111+
private static string ResolveParameterValue(ParameterResource parameter, DockerComposeEnvironmentContext context)
112112
{
113113
// Placeholder for resolving the actual parameter value
114114
// https://docs.docker.com/compose/how-tos/environment-variables/variable-interpolation/#interpolation-syntax
@@ -123,7 +123,7 @@ private static string ResolveParameterValue(ParameterResource parameter, DockerC
123123
return $"${{{env}}}";
124124
}
125125

126-
private static string AllocateParameter(ParameterResource parameter, DockerComposeInfrastructure.DockerComposeEnvironmentContext context)
126+
private static string AllocateParameter(ParameterResource parameter, DockerComposeEnvironmentContext context)
127127
{
128128
return ResolveParameterValue(parameter, context);
129129
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
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+
#pragma warning disable ASPIRECOMPUTE001
5+
6+
using System.Runtime.CompilerServices;
7+
using Aspire.Hosting.ApplicationModel;
8+
using Aspire.Hosting.Utils;
9+
using Xunit;
10+
11+
namespace Aspire.Hosting.Docker.Tests;
12+
13+
public class DockerComposeTests
14+
{
15+
[Fact]
16+
public async Task DockerComposeSetsComputeEnvironment()
17+
{
18+
var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
19+
20+
var composeEnv = builder.AddDockerComposeEnvironment("docker-compose");
21+
22+
// Add a container to the application
23+
var container = builder.AddContainer("service", "nginx");
24+
25+
var app = builder.Build();
26+
27+
await ExecuteBeforeStartHooksAsync(app, default);
28+
29+
Assert.Same(composeEnv.Resource, container.Resource.GetDeploymentTargetAnnotation()?.ComputeEnvironment);
30+
}
31+
32+
[UnsafeAccessor(UnsafeAccessorKind.Method, Name = "ExecuteBeforeStartHooksAsync")]
33+
private static extern Task ExecuteBeforeStartHooksAsync(DistributedApplication app, CancellationToken cancellationToken);
34+
}

0 commit comments

Comments
 (0)