Skip to content
Merged
9 changes: 4 additions & 5 deletions src/Aspire.Cli/Commands/PublishCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -49,11 +49,10 @@ protected override string[] GetRunArguments(string? fullyQualifiedOutputPath, st
{
var baseArgs = new List<string> { "--operation", "publish", "--step", "publish" };

var targetPath = fullyQualifiedOutputPath is not null
? fullyQualifiedOutputPath
: Path.Combine(Environment.CurrentDirectory, "aspire-output");

baseArgs.AddRange(["--output-path", targetPath]);
if (fullyQualifiedOutputPath is not null)
{
baseArgs.AddRange(["--output-path", fullyQualifiedOutputPath]);
}

// Add --log-level and --envionment flags if specified
var logLevel = parseResult.GetValue(_logLevelOption);
Expand Down
4 changes: 3 additions & 1 deletion src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
#pragma warning disable ASPIREAZURE001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
#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 ASPIREINTERACTION001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
#pragma warning disable ASPIREPIPELINES004 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.

using System.Diagnostics.CodeAnalysis;
using Aspire.Hosting.ApplicationModel;
Expand Down Expand Up @@ -121,8 +122,9 @@ public AzureEnvironmentResource(string name, ParameterResource location, Paramet
private Task PublishAsync(PipelineStepContext context)
{
var azureProvisioningOptions = context.Services.GetRequiredService<IOptions<AzureProvisioningOptions>>();
var outputService = context.Services.GetRequiredService<IPipelineOutputService>();
var publishingContext = new AzurePublishingContext(
context.OutputPath ?? throw new InvalidOperationException("OutputPath is required for Azure publishing."),
outputService.GetOutputDirectory(),
azureProvisioningOptions.Value,
context.Services,
context.Logger,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ internal sealed class DockerComposePublishingContext(
UnixFileMode.OtherRead | UnixFileMode.OtherWrite;

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

internal async Task WriteModelAsync(DistributedApplicationModel model, DockerComposeEnvironmentResource environment)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ internal sealed class KubernetesPublishingContext(
ILogger logger,
CancellationToken cancellationToken = default)
{
public readonly string OutputPath = outputPath ?? throw new InvalidOperationException("OutputPath is required for Kubernetes publishing.");
public readonly string OutputPath = outputPath;

private readonly Dictionary<string, Dictionary<string, object>> _helmValues = new()
{
Expand Down
2 changes: 2 additions & 0 deletions src/Aspire.Hosting/DistributedApplicationBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
#pragma warning disable ASPIREPIPELINES003
#pragma warning disable ASPIREPIPELINES001
#pragma warning disable ASPIREPIPELINES002
#pragma warning disable ASPIREPIPELINES004

using System.Diagnostics;
using System.Reflection;
Expand Down Expand Up @@ -462,6 +463,7 @@ public DistributedApplicationBuilder(DistributedApplicationOptions options)
_innerBuilder.Services.AddSingleton<IResourceContainerImageBuilder, ResourceContainerImageBuilder>();
_innerBuilder.Services.AddSingleton<PipelineActivityReporter>();
_innerBuilder.Services.AddSingleton<IPipelineActivityReporter, PipelineActivityReporter>(sp => sp.GetRequiredService<PipelineActivityReporter>());
_innerBuilder.Services.AddSingleton<IPipelineOutputService, PipelineOutputService>();
_innerBuilder.Services.AddSingleton(Pipeline);

// Configure pipeline logging options
Expand Down
41 changes: 41 additions & 0 deletions src/Aspire.Hosting/Pipelines/IPipelineOutputService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Diagnostics.CodeAnalysis;
using Aspire.Hosting.ApplicationModel;

namespace Aspire.Hosting.Pipelines;

/// <summary>
/// Service for managing pipeline output directories.
/// </summary>
[Experimental("ASPIREPIPELINES004", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
public interface IPipelineOutputService
{
/// <summary>
/// Gets the output directory for deployment artifacts.
/// If no output path is configured, defaults to <c>{CurrentDirectory}/aspire-output</c>.
/// </summary>
/// <returns>The path to the output directory for deployment artifacts.</returns>
string GetOutputDirectory();

/// <summary>
/// Gets the output directory for a specific resource's deployment artifacts.
/// </summary>
/// <param name="resource">The resource to get the output directory for.</param>
/// <returns>The path to the output directory for the resource's deployment artifacts.</returns>
string GetOutputDirectory(IResource resource);

/// <summary>
/// Gets a temporary directory for build artifacts.
/// </summary>
/// <returns>The path to a temporary directory for build artifacts.</returns>
string GetTempDirectory();

/// <summary>
/// Gets a temporary directory for a specific resource's build artifacts.
/// </summary>
/// <param name="resource">The resource to get the temporary directory for.</param>
/// <returns>The path to a temporary directory for the resource's build artifacts.</returns>
string GetTempDirectory(IResource resource);
}
9 changes: 1 addition & 8 deletions src/Aspire.Hosting/Pipelines/PipelineContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,15 +15,13 @@ namespace Aspire.Hosting.Pipelines;
/// <param name="serviceProvider">The service provider for dependency resolution.</param>
/// <param name="logger">The logger for pipeline operations.</param>
/// <param name="cancellationToken">The cancellation token for the pipeline operation.</param>
/// <param name="outputPath">The output path for deployment artifacts.</param>
[Experimental("ASPIREPIPELINES001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
public sealed class PipelineContext(
DistributedApplicationModel model,
DistributedApplicationExecutionContext executionContext,
IServiceProvider serviceProvider,
ILogger logger,
CancellationToken cancellationToken,
string? outputPath)
CancellationToken cancellationToken)
{
/// <summary>
/// Gets the distributed application model to be deployed.
Expand All @@ -49,9 +47,4 @@ public sealed class PipelineContext(
/// Gets the cancellation token for the pipeline operation.
/// </summary>
public CancellationToken CancellationToken { get; set; } = cancellationToken;

/// <summary>
/// Gets the output path for deployment artifacts.
/// </summary>
public string? OutputPath { get; } = outputPath;
}
81 changes: 81 additions & 0 deletions src/Aspire.Hosting/Pipelines/PipelineOutputService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Diagnostics.CodeAnalysis;
using Aspire.Hosting.ApplicationModel;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Options;

namespace Aspire.Hosting.Pipelines;

/// <summary>
/// Default implementation of <see cref="IPipelineOutputService"/>.
/// </summary>
[Experimental("ASPIREPIPELINES004", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
internal sealed class PipelineOutputService : IPipelineOutputService
{
/// <summary>
/// Stores the resolved output directory path, or <c>null</c> if not specified.
/// </summary>
private readonly string? _outputPath;

/// <summary>
/// Lazily creates and stores the path to the temporary directory for pipeline output.
/// </summary>
private readonly Lazy<string> _tempDirectory;

public PipelineOutputService(IOptions<PipelineOptions> options, IConfiguration configuration)
{
_outputPath = options.Value.OutputPath is not null ? Path.GetFullPath(options.Value.OutputPath) : null;
_tempDirectory = new Lazy<string>(() => CreateTempDirectory(configuration));
}

/// <inheritdoc/>
Copy link

Copilot AI Oct 31, 2025

Choose a reason for hiding this comment

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

The default fallback path 'aspire-output' should be documented in the XML documentation for the GetOutputDirectory() method. Currently, the documentation only states 'Gets the output directory for deployment artifacts' without mentioning that it defaults to {CurrentDirectory}/aspire-output when no output path is configured.

Suggested change
/// <inheritdoc/>
/// <summary>
/// Gets the output directory for deployment artifacts.
/// If no output path is configured, defaults to <c>{CurrentDirectory}/aspire-output</c>.
/// </summary>

Copilot uses AI. Check for mistakes.
public string GetOutputDirectory()
{
return _outputPath ?? Path.Combine(Environment.CurrentDirectory, "aspire-output");
}

/// <inheritdoc/>
public string GetOutputDirectory(IResource resource)
{
ArgumentNullException.ThrowIfNull(resource);

var baseOutputDir = GetOutputDirectory();
return Path.Combine(baseOutputDir, resource.Name);
}

/// <inheritdoc/>
public string GetTempDirectory()
{
return _tempDirectory.Value;
}

/// <inheritdoc/>
public string GetTempDirectory(IResource resource)
{
ArgumentNullException.ThrowIfNull(resource);

var baseTempDir = GetTempDirectory();
return Path.Combine(baseTempDir, resource.Name);
}

/// <summary>
/// Creates a temporary directory for pipeline build artifacts.
/// Uses AppHost:PathSha256 from configuration to create an isolated temp directory per app host,
/// enabling multiple app hosts to run concurrently without conflicts.
/// If AppHost:PathSha256 is not available, falls back to a generic "aspire" temp directory.
/// </summary>
private static string CreateTempDirectory(IConfiguration configuration)
{
var appHostSha = configuration["AppHost:PathSha256"];

if (!string.IsNullOrEmpty(appHostSha))
{
return Directory.CreateTempSubdirectory($"aspire-{appHostSha}").FullName;
Copy link
Member

Choose a reason for hiding this comment

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

How does this directory get cleaned up?

Copy link
Member

Choose a reason for hiding this comment

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

It doesnt' yet (same as all of the other temp directories today in aspire), We don't even us this yet unfortunately. Just flowing it through the system.

Copy link
Member

Choose a reason for hiding this comment

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

Should we make it implement IDisposable and delete the directory when it gets disposed?

}

// Fallback if AppHost:PathSha256 is not available
return Directory.CreateTempSubdirectory("aspire").FullName;
}
Comment on lines +69 to +80
Copy link

Copilot AI Oct 31, 2025

Choose a reason for hiding this comment

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

The purpose of using AppHost:PathSha256 for temp directory naming is not documented. Add an XML comment explaining why this SHA is used (likely for isolation between different app hosts) and what happens when it's not available.

Copilot uses AI. Check for mistakes.
}
5 changes: 0 additions & 5 deletions src/Aspire.Hosting/Pipelines/PipelineStepContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -54,9 +54,4 @@ public sealed class PipelineStepContext
/// Gets the cancellation token for the pipeline operation.
/// </summary>
public CancellationToken CancellationToken => PipelineContext.CancellationToken;

/// <summary>
/// Gets the output path for deployment artifacts.
/// </summary>
public string? OutputPath => PipelineContext.OutputPath;
}
5 changes: 1 addition & 4 deletions src/Aspire.Hosting/Publishing/PipelineExecutor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;

namespace Aspire.Hosting.Publishing;

Expand All @@ -25,7 +24,6 @@ internal sealed class PipelineExecutor(
IPipelineActivityReporter activityReporter,
IDistributedApplicationEventing eventing,
BackchannelService backchannelService,
IOptions<PipelineOptions> options,
IPipelineActivityReporter pipelineActivityReporter) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
Expand Down Expand Up @@ -99,8 +97,7 @@ await eventing.PublishAsync<AfterPublishEvent>(

public async Task ExecutePipelineAsync(DistributedApplicationModel model, CancellationToken cancellationToken)
{
var pipelineContext = new PipelineContext(model, executionContext, serviceProvider, logger, cancellationToken, options.Value.OutputPath is not null ?
Path.GetFullPath(options.Value.OutputPath) : null);
var pipelineContext = new PipelineContext(model, executionContext, serviceProvider, logger, cancellationToken);

var pipeline = serviceProvider.GetRequiredService<IDistributedApplicationPipeline>();
await pipeline.ExecuteAsync(pipelineContext).ConfigureAwait(false);
Expand Down
9 changes: 6 additions & 3 deletions src/Shared/PublishingContextUtils.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,23 +2,26 @@
// 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 ASPIREPIPELINES004 // 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.Pipelines;
using Microsoft.Extensions.DependencyInjection;

namespace Aspire.Hosting.Utils;

internal static class PublishingContextUtils
{
public static string GetEnvironmentOutputPath(PipelineStepContext context, IComputeEnvironmentResource environment)
{
var outputService = context.Services.GetRequiredService<IPipelineOutputService>();
if (context.Model.Resources.OfType<IComputeEnvironmentResource>().Count() > 1)
{
// If there are multiple compute environments, append the environment name to the output path
return Path.Combine(context.OutputPath!, environment.Name);
// If there are multiple compute environments, use resource-specific output path
return outputService.GetOutputDirectory(environment);
}

// If there is only one compute environment, use the root output path
return context.OutputPath!;
return outputService.GetOutputDirectory();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

#pragma warning disable CS0618 // Type or member is obsolete
#pragma warning disable ASPIREPIPELINES001
#pragma warning disable ASPIREPIPELINES004

using System.Text.Json;
using Aspire.Hosting.Publishing;
Expand Down Expand Up @@ -78,7 +79,8 @@ public static IDistributedApplicationPipeline AddJsonDocumentManifestPublishing(
using var stream = new MemoryStream();
using var writer = new Utf8JsonWriter(stream, new() { Indented = true });

var manifestPath = context.OutputPath ?? "aspire-manifest.json";
var outputService = context.Services.GetRequiredService<IPipelineOutputService>();
var manifestPath = outputService.GetOutputDirectory();
var publishingContext = new ManifestPublishingContext(executionContext, manifestPath, writer, context.CancellationToken);

await publishingContext.WriteModel(context.Model, context.CancellationToken).ConfigureAwait(false);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -876,8 +876,7 @@ private static PipelineContext CreateDeployingContext(DistributedApplication app
app.Services.GetRequiredService<DistributedApplicationExecutionContext>(),
app.Services,
NullLogger.Instance,
CancellationToken.None,
outputPath: null);
CancellationToken.None);
}

[Fact]
Expand Down