-
Notifications
You must be signed in to change notification settings - Fork 761
[release/13.0] Add deploy support for Docker Compose #12629
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -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; | ||||||
|
|
||||||
|
|
@@ -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> | ||||||
|
|
@@ -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); | ||||||
| } | ||||||
| })); | ||||||
| } | ||||||
|
|
||||||
|
|
@@ -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 environmentName = hostEnvironment?.EnvironmentName ?? Name; | |
| var environmentName = hostEnvironment?.EnvironmentName ?? "Production"; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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}'")] | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Without this, will people know where to look for the output?
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yep, there was some duplication here because we emit the output in the completion message for the ReportingStep. I stepped down to debug since we surface Info logs by default now. |
||
| 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.")] | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Don't we only need this? Since
dockerComposeUpStep.RequiredBy(WellKnownPipelineSteps.Deploy);.