// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
#pragma warning disable ASPIREPIPELINES001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
#pragma warning disable ASPIREPIPELINES002
#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.
#pragma warning disable ASPIRECONTAINERRUNTIME001
using System.Diagnostics.CodeAnalysis;
using Aspire.Hosting.ApplicationModel;
using Aspire.Hosting.Docker.Resources;
using Aspire.Hosting.Pipelines;
using Aspire.Hosting.Publishing;
using Aspire.Hosting.Utils;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace Aspire.Hosting.Docker;
/// <summary>
/// Represents a Docker Compose environment resource that can host application resources.
/// </summary>
/// <remarks>
/// Initializes a new instance of the <see cref="DockerComposeEnvironmentResource"/> class.
/// </remarks>
[AspireExport(ExposeProperties = true, ExposeMethods = true)]
public class DockerComposeEnvironmentResource : Resource, IComputeEnvironmentResource, IComputeEnvironmentWithVolumeMounts
{
private const string DockerComposeUpTag = "docker-compose-up";
/// <summary>
/// The name of an existing network to be used.
/// </summary>
public string? DefaultNetworkName { get; set; }
/// <summary>
/// Determines whether to include an Aspire dashboard for telemetry visualization in this environment.
/// </summary>
public bool DashboardEnabled { get; set; } = true;
internal Action<ComposeFile>? ConfigureComposeFile { get; set; }
internal Action<IDictionary<string, CapturedEnvironmentVariable>>? ConfigureEnvFile { get; set; }
internal IResourceBuilder<DockerComposeAspireDashboardResource>? Dashboard { get; set; }
/// <summary>
/// Gets the collection of environment variables captured from the Docker Compose environment.
/// These will be populated into a top-level .env file adjacent to the Docker Compose file.
/// </summary>
internal Dictionary<string, CapturedEnvironmentVariable> CapturedEnvironmentVariables { get; } = [];
internal Dictionary<IResource, DockerComposeServiceResource> ResourceMapping { get; } = new(new ResourceNameComparer());
internal IPortAllocator PortAllocator { get; } = new PortAllocator();
/// <param name="name">The name of the Docker Compose environment.</param>
public DockerComposeEnvironmentResource(string name) : base(name)
{
Annotations.Add(new PipelineStepAnnotation(async (factoryContext) =>
{
var model = factoryContext.PipelineContext.Model;
var steps = new List<PipelineStep>();
var prepareDeploymentTargetsStep = new PipelineStep
{
Name = $"prepare-deployment-targets-{Name}",
Description = $"Prepares Docker Compose deployment targets for {Name}.",
Action = ctx => PrepareDeploymentTargetsAsync(ctx),
DependsOnSteps = [WellKnownPipelineSteps.ValidateComputeEnvironments],
RequiredBySteps = [WellKnownPipelineSteps.BeforeStart]
};
steps.Add(prepareDeploymentTargetsStep);
var publishStep = new PipelineStep
{
Name = $"publish-{Name}",
Description = $"Publishes the Docker Compose environment configuration for {Name}.",
Action = ctx => PublishAsync(ctx)
};
publishStep.RequiredBy(WellKnownPipelineSteps.Publish);
steps.Add(publishStep);
// Expand deployment target steps for all compute resources (including dashboard if enabled)
var resources = DashboardEnabled && Dashboard?.Resource is DockerComposeAspireDashboardResource dashboard
? [.. model.GetComputeResources(), dashboard]
: model.GetComputeResources();
foreach (var resource in resources)
{
var deploymentTarget = resource.GetDeploymentTargetAnnotation(this)?.DeploymentTarget;
if (deploymentTarget != null && deploymentTarget.TryGetAnnotationsOfType<PipelineStepAnnotation>(out var annotations))
{
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)
{
step.Resource ??= deploymentTarget;
}
steps.AddRange(deploymentTargetSteps);
}
}
}
var prepareStep = new PipelineStep
{
Name = $"prepare-{Name}",
Description = $"Prepares the Docker Compose environment {Name} for deployment.",
Action = ctx => PrepareAsync(ctx),
DependsOnSteps = [WellKnownPipelineSteps.ValidateComputeEnvironments]
};
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 = [DockerComposeUpTag],
DependsOnSteps = [$"prepare-{Name}"]
};
dockerComposeUpStep.RequiredBy(WellKnownPipelineSteps.Deploy);
steps.Add(dockerComposeUpStep);
var dockerComposeDestroyStep = new PipelineStep
{
Name = $"destroy-compose-{Name}",
Description = $"Confirms and destroys the Docker Compose environment {Name}.",
Action = async ctx =>
{
// Check deployment state to verify this environment was actually deployed
var deploymentStateManager = ctx.Services.GetRequiredService<IDeploymentStateManager>();
var stateSection = await deploymentStateManager.AcquireSectionAsync($"DockerCompose:{Name}", ctx.CancellationToken).ConfigureAwait(false);
var savedComposeFilePath = stateSection.Data["ComposeFilePath"]?.ToString();
if (string.IsNullOrEmpty(savedComposeFilePath))
{
await ctx.ReportingStep.CompleteAsync(
$"No Docker Compose deployment state found for '{Name}'. Nothing to destroy.",
CompletionState.Completed,
ctx.CancellationToken).ConfigureAwait(false);
return;
}
await ConfirmDestroyAsync(ctx, Name).ConfigureAwait(false);
// Use saved state to build the compose context — don't recompute from current model
// Only use the project name for down — the compose file may not be valid for down
// (e.g., services with build contexts that no longer exist)
var savedOutputPath = stateSection.Data["OutputPath"]?.ToString() ?? Path.GetDirectoryName(savedComposeFilePath)!;
var savedProjectName = stateSection.Data["ProjectName"]?.ToString() ?? GetDockerComposeProjectName(ctx, this);
var runtime = await ctx.Services.GetRequiredService<IContainerRuntimeResolver>().ResolveAsync(ctx.CancellationToken).ConfigureAwait(false);
var composeContext = new ComposeOperationContext
{
ProjectName = savedProjectName,
WorkingDirectory = savedOutputPath
};
var deployTask = await ctx.ReportingStep.CreateTaskAsync(
new MarkdownString($"Running compose down for **{Name}** using **{runtime.Name}**"),
ctx.CancellationToken).ConfigureAwait(false);
await using (deployTask.ConfigureAwait(false))
{
await runtime.ComposeDownAsync(composeContext, ctx.CancellationToken).ConfigureAwait(false);
await deployTask.CompleteAsync(
new MarkdownString($"Compose shutdown complete for **{Name}** ({runtime.Name})"),
CompletionState.Completed,
ctx.CancellationToken).ConfigureAwait(false);
}
ctx.Summary.Add("🗑️ Compose", Name);
// Clean up deployment state only after successful teardown
await deploymentStateManager.DeleteSectionAsync(stateSection, ctx.CancellationToken).ConfigureAwait(false);
},
DependsOnSteps = [WellKnownPipelineSteps.DestroyPrereq]
};
dockerComposeDestroyStep.RequiredBy(WellKnownPipelineSteps.Destroy);
steps.Add(dockerComposeDestroyStep);
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 for all compute resources (including dashboard if enabled)
var resources = DashboardEnabled && Dashboard?.Resource is DockerComposeAspireDashboardResource dashboard
? [.. context.Model.GetComputeResources(), dashboard]
: context.Model.GetComputeResources();
foreach (var resource in resources)
{
var deploymentTarget = resource.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);
}
}
// Ensure print-summary steps from deployment targets run after docker-compose-up
var printSummarySteps = context.GetSteps(deploymentTarget, "print-summary");
var dockerComposeUpSteps = context.GetSteps(this, "docker-compose-up");
printSummarySteps.DependsOn(dockerComposeUpSteps);
}
// 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);
}
// This ensures that resources that have to be pushed before deployments are handled
foreach (var pushResource in context.Model.GetBuildAndPushResources())
{
var pushSteps = context.GetSteps(pushResource, WellKnownPipelineTags.PushContainerImage);
var dockerComposeUpSteps = context.GetSteps(this, DockerComposeUpTag);
dockerComposeUpSteps.DependsOn(pushSteps);
}
}));
}
/// <inheritdoc/>
[Experimental("ASPIRECOMPUTE002", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
public ReferenceExpression GetHostAddressExpression(EndpointReference endpointReference)
{
var resource = endpointReference.Resource;
// In Docker Compose, services can communicate using their service names
// Docker Compose automatically creates a network where services can reach each other by service name
return ReferenceExpression.Create($"{resource.Name.ToLowerInvariant()}");
}
private Task PublishAsync(PipelineStepContext context)
{
var outputPath = PublishingContextUtils.GetEnvironmentOutputPath(context, this);
var imageBuilder = context.Services.GetRequiredService<IResourceContainerImageManager>();
var dockerComposePublishingContext = new DockerComposePublishingContext(
context.ExecutionContext,
imageBuilder,
outputPath,
context.Logger,
context.ReportingStep,
context.CancellationToken);
return dockerComposePublishingContext.WriteModelAsync(context.Model, this);
}
/// <summary>
/// Materializes Docker Compose deployment targets for compute resources targeted to this
/// environment. Invoked by the per-environment <c>prepare-deployment-targets-{name}</c>
/// pipeline step.
/// </summary>
private async Task PrepareDeploymentTargetsAsync(PipelineStepContext context)
{
var appModel = context.Model;
var services = context.Services;
var executionContext = context.ExecutionContext;
if (executionContext.IsRunMode)
{
return;
}
var logger = services.GetRequiredService<ILogger<DockerComposeEnvironmentResource>>();
var cancellationToken = context.CancellationToken;
var dockerComposeEnvironmentContext = new DockerComposeEnvironmentContext(this, logger);
if (DashboardEnabled && Dashboard?.Resource is DockerComposeAspireDashboardResource dashboard && !IsAlreadyTargeted(dashboard))
{
// Ensure the dashboard resource is created (even though it's not part of the main application model)
var dashboardService = await dockerComposeEnvironmentContext.CreateDockerComposeServiceResourceAsync(dashboard, executionContext, cancellationToken).ConfigureAwait(false);
dashboard.Annotations.Add(new DeploymentTargetAnnotation(dashboardService)
{
ComputeEnvironment = this,
ContainerRegistry = GetContainerRegistry(this, appModel)
});
}
foreach (var r in appModel.GetComputeResources())
{
// Skip resources that are explicitly targeted to a different compute environment
var resourceComputeEnvironment = r.GetComputeEnvironment();
if (resourceComputeEnvironment is not null && resourceComputeEnvironment != this)
{
continue;
}
// This step is reachable from two pipeline executions: it is RequiredBy
// "before-start" (so it runs during AppHost startup) and it is also part of the
// publish/deploy DAG. Adding a second DeploymentTargetAnnotation on the second
// pass makes ResourceExtensions.GetDeploymentTargetAnnotation throw, so the step
// has to be idempotent. Skipping early also avoids re-running ConfigureOtlp,
// which would append duplicate environment variables.
if (IsAlreadyTargeted(r))
{
continue;
}
// Configure OTLP for resources if dashboard is enabled (before creating the service resource)
if (DashboardEnabled && Dashboard?.Resource.OtlpGrpcEndpoint is EndpointReference otlpGrpcEndpoint)
{
ConfigureOtlp(r, otlpGrpcEndpoint);
}
// Create a Docker Compose compute resource for the resource
var serviceResource = await dockerComposeEnvironmentContext.CreateDockerComposeServiceResourceAsync(r, executionContext, cancellationToken).ConfigureAwait(false);
// Add deployment target annotation to the resource
r.Annotations.Add(new DeploymentTargetAnnotation(serviceResource)
{
ComputeEnvironment = this,
ContainerRegistry = GetContainerRegistry(this, appModel)
});
}
}
/// <summary>
/// Returns <see langword="true"/> when the resource already carries a
/// <see cref="DeploymentTargetAnnotation"/> produced by this environment.
/// </summary>
private bool IsAlreadyTargeted(IResource resource) =>
resource.Annotations.OfType<DeploymentTargetAnnotation>().Any(a => a.ComputeEnvironment == this);
private static IContainerRegistry GetContainerRegistry(DockerComposeEnvironmentResource environment, DistributedApplicationModel appModel)
{
// Check for explicit container registry reference annotation on the environment
if (environment.TryGetLastAnnotation<ContainerRegistryReferenceAnnotation>(out var annotation))
{
return annotation.Registry;
}
// Check if there's a single container registry in the app model
var registries = appModel.Resources.OfType<IContainerRegistry>().ToArray();
if (registries.Length == 1)
{
return registries[0];
}
// Fall back to local container registry for Docker Compose scenarios
return LocalContainerRegistry.Instance;
}
private static void ConfigureOtlp(IResource resource, EndpointReference otlpEndpoint)
{
// Only configure OTLP for resources that have the OtlpExporterAnnotation and implement IResourceWithEnvironment
if (resource is IResourceWithEnvironment resourceWithEnv && resource.Annotations.OfType<OtlpExporterAnnotation>().Any())
{
// Configure OTLP environment variables
resourceWithEnv.Annotations.Add(new EnvironmentCallbackAnnotation(context =>
{
context.EnvironmentVariables[KnownOtelConfigNames.ExporterOtlpEndpoint] = otlpEndpoint;
context.EnvironmentVariables[KnownOtelConfigNames.ExporterOtlpProtocol] = "grpc";
context.EnvironmentVariables[KnownOtelConfigNames.ServiceName] = resource.Name;
return Task.CompletedTask;
}));
}
}
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 runtime = await context.Services.GetRequiredService<IContainerRuntimeResolver>().ResolveAsync(context.CancellationToken).ConfigureAwait(false);
var deployTask = await context.ReportingStep.CreateTaskAsync(
new MarkdownString($"Running compose up for **{Name}** using **{runtime.Name}**"),
context.CancellationToken).ConfigureAwait(false);
await using (deployTask.ConfigureAwait(false))
{
try
{
var composeContext = CreateComposeOperationContext(context);
await runtime.ComposeUpAsync(composeContext, context.CancellationToken).ConfigureAwait(false);
// Persist deployment state so destroy can find the compose file and project name
var deploymentStateManager = context.Services.GetRequiredService<IDeploymentStateManager>();
var stateSection = await deploymentStateManager.AcquireSectionAsync($"DockerCompose:{Name}", context.CancellationToken).ConfigureAwait(false);
stateSection.Data["OutputPath"] = outputPath;
stateSection.Data["ProjectName"] = composeContext.ProjectName;
stateSection.Data["ComposeFilePath"] = composeContext.ComposeFilePath;
await deploymentStateManager.SaveSectionAsync(stateSection, context.CancellationToken).ConfigureAwait(false);
await deployTask.CompleteAsync(
new MarkdownString($"Service **{Name}** is now running with Docker Compose locally (runtime: {runtime.Name})"),
CompletionState.Completed,
context.CancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
await deployTask.CompleteAsync($"Compose deployment failed ({runtime.Name}): {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}'. " +
$"If you deployed with a custom --output-path, pass the same path to the destroy command.");
}
var runtime = await context.Services.GetRequiredService<IContainerRuntimeResolver>().ResolveAsync(context.CancellationToken).ConfigureAwait(false);
var deployTask = await context.ReportingStep.CreateTaskAsync(
new MarkdownString($"Running compose down for **{Name}** using **{runtime.Name}**"),
context.CancellationToken).ConfigureAwait(false);
await using (deployTask.ConfigureAwait(false))
{
try
{
var composeContext = CreateComposeOperationContext(context);
await runtime.ComposeDownAsync(composeContext, context.CancellationToken).ConfigureAwait(false);
await deployTask.CompleteAsync(
new MarkdownString($"Compose shutdown complete for **{Name}** ({runtime.Name})"),
CompletionState.Completed,
context.CancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
await deployTask.CompleteAsync($"Compose shutdown failed ({runtime.Name}): {ex.Message}", CompletionState.CompletedWithError, context.CancellationToken).ConfigureAwait(false);
throw;
}
}
}
private static async Task ConfirmDestroyAsync(PipelineStepContext context, string environmentName)
{
var options = context.Services.GetRequiredService<IOptions<PipelineOptions>>();
if (!options.Value.SkipConfirmation)
{
var interactionService = context.Services.GetRequiredService<IInteractionService>();
if (!interactionService.IsAvailable)
{
throw new InvalidOperationException(
"Cannot perform destructive operation without confirmation. Use --yes to skip the confirmation prompt in non-interactive mode.");
}
var result = await interactionService.PromptNotificationAsync(
"Destroy environment",
$"Shut down Docker Compose environment '{environmentName}'? This will stop and remove all containers, networks, and volumes.",
new NotificationInteractionOptions
{
Intent = MessageIntent.Confirmation,
ShowSecondaryButton = true,
ShowDismiss = false,
PrimaryButtonText = "Destroy",
SecondaryButtonText = "Cancel"
},
context.CancellationToken).ConfigureAwait(false);
if (result.Canceled || !result.Data)
{
context.Logger.LogInformation("User canceled the destroy operation.");
throw new OperationCanceledException("Destroy operation canceled by user.");
}
}
}
private async Task PrepareAsync(PipelineStepContext context)
{
var envFilePath = GetEnvFilePath(context, this);
if (CapturedEnvironmentVariables.Count == 0)
{
return;
}
// Initialize a new EnvFile for this environment
var envFile = EnvFile.Create(envFilePath, context.Logger);
foreach (var entry in CapturedEnvironmentVariables)
{
var envVar = entry.Value;
var defaultValue = envVar.DefaultValue;
// Only resolve from the parameter if no static default is already set;
// a caller that provides an explicit default intends to skip parameter resolution.
if (envVar.Source is ParameterResource parameter)
{
defaultValue ??= await parameter.GetValueAsync(context.CancellationToken).ConfigureAwait(false);
}
else if (envVar.Source is IValueProvider vp)
{
// IValueProvider sources are always resolved dynamically — a static default is never used.
defaultValue = await vp.GetValueAsync(context.CancellationToken).ConfigureAwait(false);
}
envFile.Add(entry.Key, defaultValue, envVar.Description, onlyIfMissing: false);
}
envFile.Save(includeValues: true);
}
internal string AddEnvironmentVariable(string name, string? description = null, string? defaultValue = null, object? source = null, IResource? resource = null)
{
CapturedEnvironmentVariables[name] = new CapturedEnvironmentVariable
{
Name = name,
Description = description,
DefaultValue = defaultValue,
Source = source,
Resource = resource
};
return $"${{{name}}}";
}
internal static string GetEnvFilePath(PipelineStepContext context, DockerComposeEnvironmentResource environment)
{
var outputPath = PublishingContextUtils.GetEnvironmentOutputPath(context, environment);
var hostEnvironment = context.Services.GetService<Microsoft.Extensions.Hosting.IHostEnvironment>();
var environmentName = hostEnvironment?.EnvironmentName ?? environment.Name;
var envFilePath = Path.Combine(outputPath, $".env.{environmentName}");
return envFilePath;
}
internal ComposeOperationContext CreateComposeOperationContext(PipelineStepContext context)
{
var outputPath = PublishingContextUtils.GetEnvironmentOutputPath(context, this);
return new ComposeOperationContext
{
ComposeFilePath = Path.Combine(outputPath, "docker-compose.yaml"),
ProjectName = GetDockerComposeProjectName(context, this),
EnvFilePath = GetEnvFilePath(context, this),
WorkingDirectory = outputPath
};
}
internal static string GetDockerComposeProjectName(PipelineStepContext context, DockerComposeEnvironmentResource environment)
{
// Get the AppHost:PathSha256 from configuration to disambiguate projects
var configuration = context.Services.GetService<IConfiguration>();
var appHostSha = configuration?["AppHost:PathSha256"];
if (!string.IsNullOrEmpty(appHostSha) && appHostSha.Length >= 8)
{
// Use first 8 characters of the hash for readability
// Format: aspire-{environmentName}-{sha8}
return $"aspire-{environment.Name.ToLowerInvariant()}-{appHostSha[..8].ToLowerInvariant()}";
}
// Fallback to just using the environment name if PathSha256 is not available
return $"aspire-{environment.Name.ToLowerInvariant()}";
}
}