Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/Aspire.Hosting.Docker/Aspire.Hosting.Docker.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@
</PropertyGroup>

<ItemGroup>
<Compile Include="..\Aspire.Hosting\Dcp\Process\ProcessResult.cs" Link="Shared\ProcessResult.cs" />
<Compile Include="..\Aspire.Hosting\Dcp\Process\ProcessSpec.cs" Link="Shared\ProcessSpec.cs" />
<Compile Include="..\Aspire.Hosting\Dcp\Process\ProcessUtil.cs" Link="Shared\ProcessUtil.cs" />
<Compile Include="$(SharedDir)ResourceNameComparer.cs" LinkBase="Shared" />
<Compile Include="$(SharedDir)PublishingContextUtils.cs" LinkBase="Shared" />
<Compile Include="$(SharedDir)PortAllocator.cs" LinkBase="Shared" />
Expand Down
279 changes: 270 additions & 9 deletions src/Aspire.Hosting.Docker/DockerComposeEnvironmentResource.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,13 @@
#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.

using Aspire.Hosting.ApplicationModel;
using Aspire.Hosting.Dcp.Process;
using Aspire.Hosting.Docker.Resources;
using Aspire.Hosting.Pipelines;
using Aspire.Hosting.Publishing;
using Aspire.Hosting.Utils;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;

namespace Aspire.Hosting.Docker;

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

/// <summary>
/// Determines whether to build container images for the resources in this environment.
/// </summary>
public bool BuildContainerImages { get; set; } = true;

/// <summary>
/// Determines whether to include an Aspire dashboard for telemetry visualization in this environment.
/// </summary>
Expand All @@ -53,20 +50,120 @@ public class DockerComposeEnvironmentResource : Resource, IComputeEnvironmentRes

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

internal EnvFile? SharedEnvFile { get; set; }

internal PortAllocator PortAllocator { get; } = new();

/// <param name="name">The name of the Docker Compose environment.</param>
public DockerComposeEnvironmentResource(string name) : base(name)
{
Annotations.Add(new PipelineStepAnnotation(context =>
Annotations.Add(new PipelineStepAnnotation(async (factoryContext) =>
{
var step = new PipelineStep
var model = factoryContext.PipelineContext.Model;
var steps = new List<PipelineStep>();

var publishStep = new PipelineStep
{
Name = $"publish-{Name}",
Action = ctx => PublishAsync(ctx)
};
step.RequiredBy(WellKnownPipelineSteps.Publish);
return step;
publishStep.RequiredBy(WellKnownPipelineSteps.Publish);
steps.Add(publishStep);

// Expand deployment target steps for all compute resources
foreach (var computeResource in model.GetComputeResources())
{
var deploymentTarget = computeResource.GetDeploymentTargetAnnotation(this)?.DeploymentTarget;

if (deploymentTarget != null && deploymentTarget.TryGetAnnotationsOfType<PipelineStepAnnotation>(out var annotations))
{
// Resolve the deployment target's PipelineStepAnnotation and expand its steps
// We do this because the deployment target is not in the model
foreach (var annotation in annotations)
{
var childFactoryContext = new PipelineStepFactoryContext
{
PipelineContext = factoryContext.PipelineContext,
Resource = deploymentTarget
};

var deploymentTargetSteps = await annotation.CreateStepsAsync(childFactoryContext).ConfigureAwait(false);

foreach (var step in deploymentTargetSteps)
{
// Ensure the step is associated with the deployment target resource
step.Resource ??= deploymentTarget;
}

steps.AddRange(deploymentTargetSteps);
}
}
}

var prepareStep = new PipelineStep
{
Name = $"prepare-{Name}",
Action = ctx => PrepareAsync(ctx)
};
prepareStep.DependsOn(WellKnownPipelineSteps.Publish);
prepareStep.DependsOn(WellKnownPipelineSteps.Build);
steps.Add(prepareStep);

var dockerComposeUpStep = new PipelineStep
{
Name = $"docker-compose-up-{Name}",
Action = ctx => DockerComposeUpAsync(ctx),
Tags = ["docker-compose-up"],
DependsOnSteps = [$"prepare-{Name}"]
};
dockerComposeUpStep.RequiredBy(WellKnownPipelineSteps.Deploy);
steps.Add(dockerComposeUpStep);

var dockerComposeDownStep = new PipelineStep
{
Name = $"docker-compose-down-{Name}",
Action = ctx => DockerComposeDownAsync(ctx),
Tags = ["docker-compose-down"]
};
steps.Add(dockerComposeDownStep);

return steps;
}));

// Add pipeline configuration annotation to wire up dependencies
// This is where we wire up the build steps created by the resources
Annotations.Add(new PipelineConfigurationAnnotation(context =>
{
// Wire up build step dependencies
// Build steps are created by ProjectResource and ContainerResource
foreach (var computeResource in context.Model.GetComputeResources())
{
var deploymentTarget = computeResource.GetDeploymentTargetAnnotation(this)?.DeploymentTarget;

if (deploymentTarget is null)
{
continue;
}

// Execute the PipelineConfigurationAnnotation callbacks on the deployment target
if (deploymentTarget.TryGetAnnotationsOfType<PipelineConfigurationAnnotation>(out var annotations))
{
foreach (var annotation in annotations)
{
annotation.Callback(context);
}
}
}

// This ensures that resources that have to be built before deployments are handled
foreach (var computeResource in context.Model.GetBuildResources())
{
var buildSteps = context.GetSteps(computeResource, WellKnownPipelineTags.BuildCompute);

buildSteps.RequiredBy(WellKnownPipelineSteps.Deploy)
.RequiredBy($"docker-compose-up-{Name}")
.DependsOn(WellKnownPipelineSteps.DeployPrereq);
}
}));
}

Expand Down Expand Up @@ -100,10 +197,174 @@ private Task PublishAsync(PipelineStepContext context)
return dockerComposePublishingContext.WriteModelAsync(context.Model, this);
}

private async Task DockerComposeUpAsync(PipelineStepContext context)
{
var outputPath = PublishingContextUtils.GetEnvironmentOutputPath(context, this);
var dockerComposeFilePath = Path.Combine(outputPath, "docker-compose.yaml");
var envFilePath = GetEnvFilePath(context);

if (!File.Exists(dockerComposeFilePath))
{
throw new InvalidOperationException($"Docker Compose file not found at {dockerComposeFilePath}");
}

var deployTask = await context.ReportingStep.CreateTaskAsync($"Running docker compose up for **{Name}**", context.CancellationToken).ConfigureAwait(false);
await using (deployTask.ConfigureAwait(false))
{
try
{
var arguments = $"compose -f \"{dockerComposeFilePath}\"";

if (File.Exists(envFilePath))
{
arguments += $" --env-file \"{envFilePath}\"";
}

arguments += " up -d --remove-orphans";

var spec = new ProcessSpec("docker")
{
Arguments = arguments,
WorkingDirectory = outputPath,
ThrowOnNonZeroReturnCode = false,
InheritEnv = true,
OnOutputData = output =>
{
context.Logger.LogDebug("docker compose up (stdout): {Output}", output);
},
OnErrorData = error =>
{
context.Logger.LogDebug("docker compose up (stderr): {Error}", error);
},
};

var (pendingProcessResult, processDisposable) = ProcessUtil.Run(spec);

await using (processDisposable)
{
var processResult = await pendingProcessResult
.WaitAsync(context.CancellationToken)
.ConfigureAwait(false);

if (processResult.ExitCode != 0)
{
await deployTask.FailAsync($"docker compose up failed with exit code {processResult.ExitCode}", cancellationToken: context.CancellationToken).ConfigureAwait(false);
}
else
{
await deployTask.CompleteAsync($"Service **{Name}** is now running with Docker Compose locally", CompletionState.Completed, context.CancellationToken).ConfigureAwait(false);
}
}
}
catch (Exception ex)
{
await deployTask.CompleteAsync($"Docker Compose deployment failed: {ex.Message}", CompletionState.CompletedWithError, context.CancellationToken).ConfigureAwait(false);
throw;
}
}
}

private async Task DockerComposeDownAsync(PipelineStepContext context)
{
var outputPath = PublishingContextUtils.GetEnvironmentOutputPath(context, this);
var dockerComposeFilePath = Path.Combine(outputPath, "docker-compose.yaml");
var envFilePath = GetEnvFilePath(context);

if (!File.Exists(dockerComposeFilePath))
{
throw new InvalidOperationException($"Docker Compose file not found at {dockerComposeFilePath}");
}

var deployTask = await context.ReportingStep.CreateTaskAsync($"Running docker compose down for **{Name}**", context.CancellationToken).ConfigureAwait(false);
await using (deployTask.ConfigureAwait(false))
{
try
{
var arguments = $"compose -f \"{dockerComposeFilePath}\"";

if (File.Exists(envFilePath))
{
arguments += $" --env-file \"{envFilePath}\"";
}

arguments += " down";

var spec = new ProcessSpec("docker")
{
Arguments = arguments,
WorkingDirectory = outputPath,
ThrowOnNonZeroReturnCode = false,
InheritEnv = true
};

var (pendingProcessResult, processDisposable) = ProcessUtil.Run(spec);

await using (processDisposable)
{
var processResult = await pendingProcessResult
.WaitAsync(context.CancellationToken)
.ConfigureAwait(false);

if (processResult.ExitCode != 0)
{
await deployTask.FailAsync($"docker compose down failed with exit code {processResult.ExitCode}", cancellationToken: context.CancellationToken).ConfigureAwait(false);
}
else
{
await deployTask.CompleteAsync($"Docker Compose shutdown complete for **{Name}**", CompletionState.Completed, context.CancellationToken).ConfigureAwait(false);
}
}
}
catch (Exception ex)
{
await deployTask.CompleteAsync($"Docker Compose shutdown failed: {ex.Message}", CompletionState.CompletedWithError, context.CancellationToken).ConfigureAwait(false);
throw;
}
}
}

private async Task PrepareAsync(PipelineStepContext context)
{
var envFilePath = GetEnvFilePath(context);

if (CapturedEnvironmentVariables.Count == 0 || SharedEnvFile is null)
{
return;
}

foreach (var entry in CapturedEnvironmentVariables)
{
var (key, (description, defaultValue, source)) = entry;

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

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

SharedEnvFile.Add(key, defaultValue, description, onlyIfMissing: false);
}

SharedEnvFile.Save(envFilePath, includeValues: true);
}

internal string AddEnvironmentVariable(string name, string? description = null, string? defaultValue = null, object? source = null)
{
CapturedEnvironmentVariables[name] = (description, defaultValue, source);

return $"${{{name}}}";
}

private string GetEnvFilePath(PipelineStepContext context)
{
var outputPath = PublishingContextUtils.GetEnvironmentOutputPath(context, this);
var hostEnvironment = context.Services.GetService<Microsoft.Extensions.Hosting.IHostEnvironment>();
var environmentName = hostEnvironment?.EnvironmentName ?? Name;
var envFilePath = Path.Combine(outputPath, $".env.{environmentName}");
return envFilePath;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ internal static partial class DockerComposePublisherLoggerExtensions
[LoggerMessage(LogLevel.Information, "No resources found in the model.")]
internal static partial void EmptyModel(this ILogger logger);

[LoggerMessage(LogLevel.Information, "Successfully generated Compose output in '{OutputPath}'")]
[LoggerMessage(LogLevel.Debug, "Successfully generated Compose output in '{OutputPath}'")]
internal static partial void FinishGeneratingDockerCompose(this ILogger logger, string outputPath);

[LoggerMessage(LogLevel.Warning, "Failed to get container image for resource '{ResourceName}', it will be skipped in the output.")]
Expand Down
Loading