Skip to content
Closed
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
203 changes: 191 additions & 12 deletions src/Aspire.Hosting.Docker/DockerComposeEnvironmentResource.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,11 @@
#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;

namespace Aspire.Hosting.Docker;

Expand All @@ -31,11 +31,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 @@ -58,15 +53,103 @@ public class DockerComposeEnvironmentResource : Resource, IComputeEnvironmentRes
/// <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);

var dockerComposeUpStep = new PipelineStep
{
Name = $"docker-compose-up-{Name}",
Action = ctx => DockerComposeUpAsync(ctx),
Tags = ["docker-compose-up"],
DependsOnSteps = [$"publish-{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);

// 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);
}
}
}

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())
{
context.GetSteps(computeResource, WellKnownPipelineTags.BuildCompute)
.RequiredBy(WellKnownPipelineSteps.Deploy)
.RequiredBy($"docker-compose-up-{Name}")
.DependsOn(WellKnownPipelineSteps.DeployPrereq);
}
}));
}

Expand All @@ -87,11 +170,9 @@ ReferenceExpression IComputeEnvironmentResource.GetHostAddressExpression(Endpoin
private Task PublishAsync(PipelineStepContext context)
{
var outputPath = PublishingContextUtils.GetEnvironmentOutputPath(context, this);
var imageBuilder = context.Services.GetRequiredService<IResourceContainerImageBuilder>();

var dockerComposePublishingContext = new DockerComposePublishingContext(
context.ExecutionContext,
imageBuilder,
outputPath,
context.Logger,
context.ReportingStep,
Expand All @@ -100,6 +181,104 @@ 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");

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 spec = new ProcessSpec("docker")
{
Arguments = $"compose -f \"{dockerComposeFilePath}\" up -d --remove-orphans",
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 up failed with exit code {processResult.ExitCode}", cancellationToken: context.CancellationToken).ConfigureAwait(false);
}
else
{
await deployTask.CompleteAsync($"Docker Compose deployment complete for **{Name}**", 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");

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 spec = new ProcessSpec("docker")
{
Arguments = $"compose -f \"{dockerComposeFilePath}\" down",
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;
}
}
}

internal string AddEnvironmentVariable(string name, string? description = null, string? defaultValue = null, object? source = null)
{
CapturedEnvironmentVariables[name] = (description, defaultValue, source);
Expand Down
22 changes: 3 additions & 19 deletions src/Aspire.Hosting.Docker/DockerComposePublishingContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@
using Aspire.Hosting.Docker.Resources.ComposeNodes;
using Aspire.Hosting.Docker.Resources.ServiceNodes;
using Aspire.Hosting.Pipelines;
using Aspire.Hosting.Publishing;
using Microsoft.Extensions.Logging;

namespace Aspire.Hosting.Docker;
Expand All @@ -25,7 +24,6 @@ namespace Aspire.Hosting.Docker;
/// </remarks>
internal sealed class DockerComposePublishingContext(
DistributedApplicationExecutionContext executionContext,
IResourceContainerImageBuilder imageBuilder,
string outputPath,
ILogger logger,
IReportingStep reportingStep,
Expand All @@ -36,7 +34,6 @@ internal sealed class DockerComposePublishingContext(
UnixFileMode.GroupRead | UnixFileMode.GroupWrite |
UnixFileMode.OtherRead | UnixFileMode.OtherWrite;

public readonly IResourceContainerImageBuilder ImageBuilder = imageBuilder;
public readonly string OutputPath = outputPath ?? throw new InvalidOperationException("OutputPath is required for Docker Compose publishing.");

internal async Task WriteModelAsync(DistributedApplicationModel model, DockerComposeEnvironmentResource environment)
Expand Down Expand Up @@ -78,17 +75,10 @@ private async Task WriteDockerComposeOutputAsync(DistributedApplicationModel mod
? [r, .. model.Resources]
: model.Resources;

var containerImagesToBuild = new List<IResource>();

foreach (var resource in resources)
{
if (resource.GetDeploymentTargetAnnotation(environment)?.DeploymentTarget is DockerComposeServiceResource serviceResource)
{
if (environment.BuildContainerImages)
{
containerImagesToBuild.Add(serviceResource.TargetResource);
}

// Materialize Dockerfile factories for resources with DockerfileBuildAnnotation
if (serviceResource.TargetResource.TryGetLastAnnotation<DockerfileBuildAnnotation>(out var dockerfileBuildAnnotation) &&
dockerfileBuildAnnotation.DockerfileFactory is not null)
Expand Down Expand Up @@ -143,12 +133,6 @@ private async Task WriteDockerComposeOutputAsync(DistributedApplicationModel mod
}
}

// Build container images for the services that require it
if (containerImagesToBuild.Count > 0)
{
await ImageBuilder.BuildImagesAsync(containerImagesToBuild, options: null, cancellationToken).ConfigureAwait(false);
}

var writeTask = await reportingStep.CreateTaskAsync(
"Writing the Docker Compose file to the output path.",
cancellationToken: cancellationToken).ConfigureAwait(false);
Expand All @@ -175,10 +159,10 @@ private async Task WriteDockerComposeOutputAsync(DistributedApplicationModel mod
var (key, (description, defaultValue, source)) = entry;
var onlyIfMissing = true;

// If the source is a parameter and there's no explicit default value,
// resolve the parameter's default value asynchronously
if (defaultValue is null && source is ParameterResource parameter && !parameter.Secret && parameter.Default is not null)
// Handle parameter resources by resolving their actual values
if (source is ParameterResource parameter)
{
// For non-secret parameters, get the actual parameter value
defaultValue = await parameter.GetValueAsync(cancellationToken).ConfigureAwait(false);
}

Expand Down
8 changes: 8 additions & 0 deletions src/Aspire.Hosting.Docker/DockerComposeServiceResource.cs
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,14 @@ internal Service BuildComposeService()
SetContainerImage(containerImageName, composeService);
}

// Disable the pull policy if the target resource requires an image build
// since we'll want to use local images built during the pipeline instead
// of pulling images from a registry.
if (TargetResource.RequiresImageBuild())
{
composeService.PullPolicy = "never";
}

SetContainerName(composeService);
SetEntryPoint(composeService);
AddEnvironmentVariablesAndCommandLineArgs(composeService);
Expand Down
30 changes: 28 additions & 2 deletions src/Aspire.Hosting.Docker/EnvFile.cs
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,36 @@ public static EnvFile Load(string path)

public void Add(string key, string? value, string? comment, bool onlyIfMissing = true)
{
if (onlyIfMissing && _keys.Contains(key))
if (_keys.Contains(key))
{
return;
if (onlyIfMissing)
{
return;
}

// If the key already exists and we want to update it (onlyIfMissing = false),
// we need to find and replace the existing entry
// Find the existing key-value line and replace it
Copy link

Copilot AI Oct 28, 2025

Choose a reason for hiding this comment

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

The comment at line 47 says "Find the existing key-value line and replace it" which is redundant with the comment at line 45-46. The comments should be consolidated or the redundant one removed.

Consider simplifying to:

// If the key already exists and we want to update it (onlyIfMissing = false),
// find and replace the existing entry
for (var i = 0; i < _lines.Count; i++)
Suggested change
// Find the existing key-value line and replace it

Copilot uses AI. Check for mistakes.
for (var i = 0; i < _lines.Count; i++)
{
var line = _lines[i].TrimStart();
if (!line.StartsWith('#') && line.Contains('='))
{
var eqIndex = line.IndexOf('=');
if (eqIndex > 0)
{
var existingKey = line[..eqIndex].Trim();
if (existingKey == key)
{
_lines[i] = value is not null ? $"{key}={value}" : $"{key}=";
return;
}
}
}
}
}

// Add new entry
if (!string.IsNullOrWhiteSpace(comment))
{
_lines.Add($"# {comment}");
Expand Down
10 changes: 10 additions & 0 deletions src/Aspire.Hosting.Docker/Resources/ComposeNodes/Service.cs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,16 @@ public sealed class Service : NamedComposeMember
[YamlMember(Alias = "image")]
public string? Image { get; set; }

/// <summary>
/// Specifies the pull policy for the Docker image.
/// </summary>
/// <remarks>
/// The pull policy determines when to pull the image from the registry.
/// It can be set to values like "always", "if-not-present", or "never".
/// </remarks>
[YamlMember(Alias = "pull_policy")]
public string? PullPolicy { get; set; }

/// <summary>
/// Specifies the name of the container to be used.
/// This property maps to the "container_name" field in a Docker Compose file.
Expand Down
Loading
Loading