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
3 changes: 2 additions & 1 deletion src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs
Original file line number Diff line number Diff line change
Expand Up @@ -121,8 +121,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
1 change: 1 addition & 0 deletions src/Aspire.Hosting/DistributedApplicationBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -462,6 +462,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
40 changes: 40 additions & 0 deletions src/Aspire.Hosting/Pipelines/IPipelineOutputService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
// 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("ASPIREPIPELINES001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
[Experimental("ASPIREPIPELINES001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
[Experimental("ASPIREPIPELINES004", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]

Let's create a new code for this to stop overloading on the code for the core APIs.

Copy link
Member

Choose a reason for hiding this comment

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

@copilot Update the diagnostic ID introduced in this Experimental attribute.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Implemented in c01acb7. Updated the diagnostic ID to ASPIREPIPELINES004 for both IPipelineOutputService and PipelineOutputService. Also added comprehensive XML documentation including default behavior for GetOutputDirectory(), field purposes, and the AppHost:PathSha256 isolation mechanism for temp directories.

public interface IPipelineOutputService
{
/// <summary>
/// Gets the output directory for deployment artifacts.
/// </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;
}
68 changes: 68 additions & 0 deletions src/Aspire.Hosting/Pipelines/PipelineOutputService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
// 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("ASPIREPIPELINES001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
internal sealed class PipelineOutputService : IPipelineOutputService
{
private readonly string? _outputPath;
Comment on lines 16 to 20
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.

These private fields lack XML documentation comments. Per the XML documentation standards, internal implementation fields should have brief summary tags explaining their purpose, especially for a service class like this.

Suggested change
{
private readonly string? _outputPath;
{
/// <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>

Copilot uses AI. Check for mistakes.
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);
}

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
8 changes: 5 additions & 3 deletions src/Shared/PublishingContextUtils.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,22 @@

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 @@ -78,7 +78,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
Loading