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
2 changes: 1 addition & 1 deletion src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ private Task PublishAsync(PipelineStepContext context)
{
var azureProvisioningOptions = context.Services.GetRequiredService<IOptions<AzureProvisioningOptions>>();
var publishingContext = new AzurePublishingContext(
context.OutputPath ?? throw new InvalidOperationException("OutputPath is required for Azure publishing."),
context.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
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);
}
6 changes: 4 additions & 2 deletions src/Aspire.Hosting/Pipelines/PipelineContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@

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

namespace Aspire.Hosting.Pipelines;
Expand Down Expand Up @@ -51,7 +53,7 @@ public sealed class PipelineContext(
public CancellationToken CancellationToken { get; set; } = cancellationToken;

/// <summary>
/// Gets the output path for deployment artifacts.
/// Gets the service for managing pipeline output directories.
/// </summary>
public string? OutputPath { get; } = outputPath;
public IPipelineOutputService OutputService { get; } = new PipelineOutputService(outputPath, serviceProvider.GetRequiredService<IConfiguration>());
Copy link
Member

Choose a reason for hiding this comment

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

@copilot remove this from PipelineContext

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 a481f66. Removed OutputService from PipelineContext and registered IPipelineOutputService in the DI container instead. Steps now explicitly resolve the service via context.Services.GetRequiredService<IPipelineOutputService>() when needed. Also removed the outputPath parameter from PipelineContext constructor since it's now handled by the DI-registered service.

}
67 changes: 67 additions & 0 deletions src/Aspire.Hosting/Pipelines/PipelineOutputService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
// 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;

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(string? outputPath, IConfiguration configuration)
{
_outputPath = outputPath;
_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.
}
4 changes: 2 additions & 2 deletions src/Aspire.Hosting/Pipelines/PipelineStepContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ public sealed class PipelineStepContext
public CancellationToken CancellationToken => PipelineContext.CancellationToken;

/// <summary>
/// Gets the output path for deployment artifacts.
/// Gets the service for managing pipeline output directories.
/// </summary>
public string? OutputPath => PipelineContext.OutputPath;
public IPipelineOutputService OutputService => PipelineContext.OutputService;
}
4 changes: 2 additions & 2 deletions src/Shared/PublishingContextUtils.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,10 @@ public static string GetEnvironmentOutputPath(PipelineStepContext context, IComp
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);
return Path.Combine(context.OutputService.GetOutputDirectory(), environment.Name);
}

// If there is only one compute environment, use the root output path
return context.OutputPath!;
return context.OutputService.GetOutputDirectory();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ 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 manifestPath = context.OutputService.GetOutputDirectory();
var publishingContext = new ManifestPublishingContext(executionContext, manifestPath, writer, context.CancellationToken);

await publishingContext.WriteModel(context.Model, context.CancellationToken).ConfigureAwait(false);
Expand Down