Skip to content

Commit 6c85567

Browse files
committed
Add deploy support for Docker Compose
1 parent 93c7f53 commit 6c85567

17 files changed

+617
-113
lines changed

src/Aspire.Hosting.Docker/Aspire.Hosting.Docker.csproj

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@
1111
</PropertyGroup>
1212

1313
<ItemGroup>
14+
<Compile Include="..\Aspire.Hosting\Dcp\Process\ProcessResult.cs" Link="Shared\ProcessResult.cs" />
15+
<Compile Include="..\Aspire.Hosting\Dcp\Process\ProcessSpec.cs" Link="Shared\ProcessSpec.cs" />
16+
<Compile Include="..\Aspire.Hosting\Dcp\Process\ProcessUtil.cs" Link="Shared\ProcessUtil.cs" />
1417
<Compile Include="$(SharedDir)ResourceNameComparer.cs" LinkBase="Shared" />
1518
<Compile Include="$(SharedDir)PublishingContextUtils.cs" LinkBase="Shared" />
1619
<Compile Include="$(SharedDir)PortAllocator.cs" LinkBase="Shared" />

src/Aspire.Hosting.Docker/DockerComposeEnvironmentResource.cs

Lines changed: 270 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,13 @@
55
#pragma warning disable ASPIREPIPELINES003 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
66

77
using Aspire.Hosting.ApplicationModel;
8+
using Aspire.Hosting.Dcp.Process;
89
using Aspire.Hosting.Docker.Resources;
910
using Aspire.Hosting.Pipelines;
1011
using Aspire.Hosting.Publishing;
1112
using Aspire.Hosting.Utils;
1213
using Microsoft.Extensions.DependencyInjection;
14+
using Microsoft.Extensions.Logging;
1315

1416
namespace Aspire.Hosting.Docker;
1517

@@ -31,11 +33,6 @@ public class DockerComposeEnvironmentResource : Resource, IComputeEnvironmentRes
3133
/// </summary>
3234
public string? DefaultNetworkName { get; set; }
3335

34-
/// <summary>
35-
/// Determines whether to build container images for the resources in this environment.
36-
/// </summary>
37-
public bool BuildContainerImages { get; set; } = true;
38-
3936
/// <summary>
4037
/// Determines whether to include an Aspire dashboard for telemetry visualization in this environment.
4138
/// </summary>
@@ -53,20 +50,120 @@ public class DockerComposeEnvironmentResource : Resource, IComputeEnvironmentRes
5350

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

53+
internal EnvFile? SharedEnvFile { get; set; }
54+
5655
internal PortAllocator PortAllocator { get; } = new();
5756

5857
/// <param name="name">The name of the Docker Compose environment.</param>
5958
public DockerComposeEnvironmentResource(string name) : base(name)
6059
{
61-
Annotations.Add(new PipelineStepAnnotation(context =>
60+
Annotations.Add(new PipelineStepAnnotation(async (factoryContext) =>
6261
{
63-
var step = new PipelineStep
62+
var model = factoryContext.PipelineContext.Model;
63+
var steps = new List<PipelineStep>();
64+
65+
var publishStep = new PipelineStep
6466
{
6567
Name = $"publish-{Name}",
6668
Action = ctx => PublishAsync(ctx)
6769
};
68-
step.RequiredBy(WellKnownPipelineSteps.Publish);
69-
return step;
70+
publishStep.RequiredBy(WellKnownPipelineSteps.Publish);
71+
steps.Add(publishStep);
72+
73+
// Expand deployment target steps for all compute resources
74+
foreach (var computeResource in model.GetComputeResources())
75+
{
76+
var deploymentTarget = computeResource.GetDeploymentTargetAnnotation(this)?.DeploymentTarget;
77+
78+
if (deploymentTarget != null && deploymentTarget.TryGetAnnotationsOfType<PipelineStepAnnotation>(out var annotations))
79+
{
80+
// Resolve the deployment target's PipelineStepAnnotation and expand its steps
81+
// We do this because the deployment target is not in the model
82+
foreach (var annotation in annotations)
83+
{
84+
var childFactoryContext = new PipelineStepFactoryContext
85+
{
86+
PipelineContext = factoryContext.PipelineContext,
87+
Resource = deploymentTarget
88+
};
89+
90+
var deploymentTargetSteps = await annotation.CreateStepsAsync(childFactoryContext).ConfigureAwait(false);
91+
92+
foreach (var step in deploymentTargetSteps)
93+
{
94+
// Ensure the step is associated with the deployment target resource
95+
step.Resource ??= deploymentTarget;
96+
}
97+
98+
steps.AddRange(deploymentTargetSteps);
99+
}
100+
}
101+
}
102+
103+
var prepareStep = new PipelineStep
104+
{
105+
Name = $"prepare-{Name}",
106+
Action = ctx => PrepareAsync(ctx)
107+
};
108+
prepareStep.DependsOn(WellKnownPipelineSteps.Publish);
109+
prepareStep.DependsOn(WellKnownPipelineSteps.Build);
110+
steps.Add(prepareStep);
111+
112+
var dockerComposeUpStep = new PipelineStep
113+
{
114+
Name = $"docker-compose-up-{Name}",
115+
Action = ctx => DockerComposeUpAsync(ctx),
116+
Tags = ["docker-compose-up"],
117+
DependsOnSteps = [$"prepare-{Name}"]
118+
};
119+
dockerComposeUpStep.RequiredBy(WellKnownPipelineSteps.Deploy);
120+
steps.Add(dockerComposeUpStep);
121+
122+
var dockerComposeDownStep = new PipelineStep
123+
{
124+
Name = $"docker-compose-down-{Name}",
125+
Action = ctx => DockerComposeDownAsync(ctx),
126+
Tags = ["docker-compose-down"]
127+
};
128+
steps.Add(dockerComposeDownStep);
129+
130+
return steps;
131+
}));
132+
133+
// Add pipeline configuration annotation to wire up dependencies
134+
// This is where we wire up the build steps created by the resources
135+
Annotations.Add(new PipelineConfigurationAnnotation(context =>
136+
{
137+
// Wire up build step dependencies
138+
// Build steps are created by ProjectResource and ContainerResource
139+
foreach (var computeResource in context.Model.GetComputeResources())
140+
{
141+
var deploymentTarget = computeResource.GetDeploymentTargetAnnotation(this)?.DeploymentTarget;
142+
143+
if (deploymentTarget is null)
144+
{
145+
continue;
146+
}
147+
148+
// Execute the PipelineConfigurationAnnotation callbacks on the deployment target
149+
if (deploymentTarget.TryGetAnnotationsOfType<PipelineConfigurationAnnotation>(out var annotations))
150+
{
151+
foreach (var annotation in annotations)
152+
{
153+
annotation.Callback(context);
154+
}
155+
}
156+
}
157+
158+
// This ensures that resources that have to be built before deployments are handled
159+
foreach (var computeResource in context.Model.GetBuildResources())
160+
{
161+
var buildSteps = context.GetSteps(computeResource, WellKnownPipelineTags.BuildCompute);
162+
163+
buildSteps.RequiredBy(WellKnownPipelineSteps.Deploy)
164+
.RequiredBy($"docker-compose-up-{Name}")
165+
.DependsOn(WellKnownPipelineSteps.DeployPrereq);
166+
}
70167
}));
71168
}
72169

@@ -100,10 +197,174 @@ private Task PublishAsync(PipelineStepContext context)
100197
return dockerComposePublishingContext.WriteModelAsync(context.Model, this);
101198
}
102199

200+
private async Task DockerComposeUpAsync(PipelineStepContext context)
201+
{
202+
var outputPath = PublishingContextUtils.GetEnvironmentOutputPath(context, this);
203+
var dockerComposeFilePath = Path.Combine(outputPath, "docker-compose.yaml");
204+
var envFilePath = GetEnvFilePath(context);
205+
206+
if (!File.Exists(dockerComposeFilePath))
207+
{
208+
throw new InvalidOperationException($"Docker Compose file not found at {dockerComposeFilePath}");
209+
}
210+
211+
var deployTask = await context.ReportingStep.CreateTaskAsync($"Running docker compose up for **{Name}**", context.CancellationToken).ConfigureAwait(false);
212+
await using (deployTask.ConfigureAwait(false))
213+
{
214+
try
215+
{
216+
var arguments = $"compose -f \"{dockerComposeFilePath}\"";
217+
218+
if (File.Exists(envFilePath))
219+
{
220+
arguments += $" --env-file \"{envFilePath}\"";
221+
}
222+
223+
arguments += " up -d --remove-orphans";
224+
225+
var spec = new ProcessSpec("docker")
226+
{
227+
Arguments = arguments,
228+
WorkingDirectory = outputPath,
229+
ThrowOnNonZeroReturnCode = false,
230+
InheritEnv = true,
231+
OnOutputData = output =>
232+
{
233+
context.Logger.LogDebug("docker compose up (stdout): {Output}", output);
234+
},
235+
OnErrorData = error =>
236+
{
237+
context.Logger.LogDebug("docker compose up (stderr): {Error}", error);
238+
},
239+
};
240+
241+
var (pendingProcessResult, processDisposable) = ProcessUtil.Run(spec);
242+
243+
await using (processDisposable)
244+
{
245+
var processResult = await pendingProcessResult
246+
.WaitAsync(context.CancellationToken)
247+
.ConfigureAwait(false);
248+
249+
if (processResult.ExitCode != 0)
250+
{
251+
await deployTask.FailAsync($"docker compose up failed with exit code {processResult.ExitCode}", cancellationToken: context.CancellationToken).ConfigureAwait(false);
252+
}
253+
else
254+
{
255+
await deployTask.CompleteAsync($"Docker Compose deployment complete for **{Name}**", CompletionState.Completed, context.CancellationToken).ConfigureAwait(false);
256+
}
257+
}
258+
}
259+
catch (Exception ex)
260+
{
261+
await deployTask.CompleteAsync($"Docker Compose deployment failed: {ex.Message}", CompletionState.CompletedWithError, context.CancellationToken).ConfigureAwait(false);
262+
throw;
263+
}
264+
}
265+
}
266+
267+
private async Task DockerComposeDownAsync(PipelineStepContext context)
268+
{
269+
var outputPath = PublishingContextUtils.GetEnvironmentOutputPath(context, this);
270+
var dockerComposeFilePath = Path.Combine(outputPath, "docker-compose.yaml");
271+
var envFilePath = GetEnvFilePath(context);
272+
273+
if (!File.Exists(dockerComposeFilePath))
274+
{
275+
throw new InvalidOperationException($"Docker Compose file not found at {dockerComposeFilePath}");
276+
}
277+
278+
var deployTask = await context.ReportingStep.CreateTaskAsync($"Running docker compose down for **{Name}**", context.CancellationToken).ConfigureAwait(false);
279+
await using (deployTask.ConfigureAwait(false))
280+
{
281+
try
282+
{
283+
var arguments = $"compose -f \"{dockerComposeFilePath}\"";
284+
285+
if (File.Exists(envFilePath))
286+
{
287+
arguments += $" --env-file \"{envFilePath}\"";
288+
}
289+
290+
arguments += " down";
291+
292+
var spec = new ProcessSpec("docker")
293+
{
294+
Arguments = arguments,
295+
WorkingDirectory = outputPath,
296+
ThrowOnNonZeroReturnCode = false,
297+
InheritEnv = true
298+
};
299+
300+
var (pendingProcessResult, processDisposable) = ProcessUtil.Run(spec);
301+
302+
await using (processDisposable)
303+
{
304+
var processResult = await pendingProcessResult
305+
.WaitAsync(context.CancellationToken)
306+
.ConfigureAwait(false);
307+
308+
if (processResult.ExitCode != 0)
309+
{
310+
await deployTask.FailAsync($"docker compose down failed with exit code {processResult.ExitCode}", cancellationToken: context.CancellationToken).ConfigureAwait(false);
311+
}
312+
else
313+
{
314+
await deployTask.CompleteAsync($"Docker Compose shutdown complete for **{Name}**", CompletionState.Completed, context.CancellationToken).ConfigureAwait(false);
315+
}
316+
}
317+
}
318+
catch (Exception ex)
319+
{
320+
await deployTask.CompleteAsync($"Docker Compose shutdown failed: {ex.Message}", CompletionState.CompletedWithError, context.CancellationToken).ConfigureAwait(false);
321+
throw;
322+
}
323+
}
324+
}
325+
326+
private async Task PrepareAsync(PipelineStepContext context)
327+
{
328+
var envFilePath = GetEnvFilePath(context);
329+
330+
if (CapturedEnvironmentVariables.Count == 0 || SharedEnvFile is null)
331+
{
332+
return;
333+
}
334+
335+
foreach (var entry in CapturedEnvironmentVariables)
336+
{
337+
var (key, (description, defaultValue, source)) = entry;
338+
339+
if (defaultValue is null && source is ParameterResource parameter)
340+
{
341+
defaultValue = await parameter.GetValueAsync(context.CancellationToken).ConfigureAwait(false);
342+
}
343+
344+
if (source is ContainerImageReference cir && cir.Resource.TryGetContainerImageName(out var imageName))
345+
{
346+
defaultValue = imageName;
347+
}
348+
349+
SharedEnvFile.Add(key, defaultValue, description, onlyIfMissing: false);
350+
}
351+
352+
SharedEnvFile.Save(envFilePath, includeValues: true);
353+
}
354+
103355
internal string AddEnvironmentVariable(string name, string? description = null, string? defaultValue = null, object? source = null)
104356
{
105357
CapturedEnvironmentVariables[name] = (description, defaultValue, source);
106358

107359
return $"${{{name}}}";
108360
}
361+
362+
private string GetEnvFilePath(PipelineStepContext context)
363+
{
364+
var outputPath = PublishingContextUtils.GetEnvironmentOutputPath(context, this);
365+
var hostEnvironment = context.Services.GetService<Microsoft.Extensions.Hosting.IHostEnvironment>();
366+
var environmentName = hostEnvironment?.EnvironmentName ?? Name;
367+
var envFilePath = Path.Combine(outputPath, $"{environmentName}.env");
368+
return envFilePath;
369+
}
109370
}

0 commit comments

Comments
 (0)