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/>
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;
}

// Fallback if AppHost:PathSha256 is not available
return Directory.CreateTempSubdirectory("aspire").FullName;
}
}
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