diff --git a/src/Aspire.Cli/Commands/PublishCommand.cs b/src/Aspire.Cli/Commands/PublishCommand.cs index 86368aa3354..f67bad3cb39 100644 --- a/src/Aspire.Cli/Commands/PublishCommand.cs +++ b/src/Aspire.Cli/Commands/PublishCommand.cs @@ -49,11 +49,10 @@ protected override string[] GetRunArguments(string? fullyQualifiedOutputPath, st { var baseArgs = new List { "--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); diff --git a/src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs b/src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs index ed79f706fe4..c635b3d34f7 100644 --- a/src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs +++ b/src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs @@ -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; @@ -121,8 +122,9 @@ public AzureEnvironmentResource(string name, ParameterResource location, Paramet private Task PublishAsync(PipelineStepContext context) { var azureProvisioningOptions = context.Services.GetRequiredService>(); + var outputService = context.Services.GetRequiredService(); var publishingContext = new AzurePublishingContext( - context.OutputPath ?? throw new InvalidOperationException("OutputPath is required for Azure publishing."), + outputService.GetOutputDirectory(), azureProvisioningOptions.Value, context.Services, context.Logger, diff --git a/src/Aspire.Hosting.Docker/DockerComposePublishingContext.cs b/src/Aspire.Hosting.Docker/DockerComposePublishingContext.cs index 0a3f629f27b..58701582ee0 100644 --- a/src/Aspire.Hosting.Docker/DockerComposePublishingContext.cs +++ b/src/Aspire.Hosting.Docker/DockerComposePublishingContext.cs @@ -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) { diff --git a/src/Aspire.Hosting.Kubernetes/KubernetesPublishingContext.cs b/src/Aspire.Hosting.Kubernetes/KubernetesPublishingContext.cs index a3811463d86..7db532a90e5 100644 --- a/src/Aspire.Hosting.Kubernetes/KubernetesPublishingContext.cs +++ b/src/Aspire.Hosting.Kubernetes/KubernetesPublishingContext.cs @@ -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> _helmValues = new() { diff --git a/src/Aspire.Hosting/DistributedApplicationBuilder.cs b/src/Aspire.Hosting/DistributedApplicationBuilder.cs index 56bcc258599..fc88bd556f8 100644 --- a/src/Aspire.Hosting/DistributedApplicationBuilder.cs +++ b/src/Aspire.Hosting/DistributedApplicationBuilder.cs @@ -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; @@ -462,6 +463,7 @@ public DistributedApplicationBuilder(DistributedApplicationOptions options) _innerBuilder.Services.AddSingleton(); _innerBuilder.Services.AddSingleton(); _innerBuilder.Services.AddSingleton(sp => sp.GetRequiredService()); + _innerBuilder.Services.AddSingleton(); _innerBuilder.Services.AddSingleton(Pipeline); // Configure pipeline logging options diff --git a/src/Aspire.Hosting/Pipelines/IPipelineOutputService.cs b/src/Aspire.Hosting/Pipelines/IPipelineOutputService.cs new file mode 100644 index 00000000000..826549d077f --- /dev/null +++ b/src/Aspire.Hosting/Pipelines/IPipelineOutputService.cs @@ -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; + +/// +/// Service for managing pipeline output directories. +/// +[Experimental("ASPIREPIPELINES004", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] +public interface IPipelineOutputService +{ + /// + /// Gets the output directory for deployment artifacts. + /// If no output path is configured, defaults to {CurrentDirectory}/aspire-output. + /// + /// The path to the output directory for deployment artifacts. + string GetOutputDirectory(); + + /// + /// Gets the output directory for a specific resource's deployment artifacts. + /// + /// The resource to get the output directory for. + /// The path to the output directory for the resource's deployment artifacts. + string GetOutputDirectory(IResource resource); + + /// + /// Gets a temporary directory for build artifacts. + /// + /// The path to a temporary directory for build artifacts. + string GetTempDirectory(); + + /// + /// Gets a temporary directory for a specific resource's build artifacts. + /// + /// The resource to get the temporary directory for. + /// The path to a temporary directory for the resource's build artifacts. + string GetTempDirectory(IResource resource); +} diff --git a/src/Aspire.Hosting/Pipelines/PipelineContext.cs b/src/Aspire.Hosting/Pipelines/PipelineContext.cs index 54c5455cb83..9baaa86874d 100644 --- a/src/Aspire.Hosting/Pipelines/PipelineContext.cs +++ b/src/Aspire.Hosting/Pipelines/PipelineContext.cs @@ -15,15 +15,13 @@ namespace Aspire.Hosting.Pipelines; /// The service provider for dependency resolution. /// The logger for pipeline operations. /// The cancellation token for the pipeline operation. -/// The output path for deployment artifacts. [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) { /// /// Gets the distributed application model to be deployed. @@ -49,9 +47,4 @@ public sealed class PipelineContext( /// Gets the cancellation token for the pipeline operation. /// public CancellationToken CancellationToken { get; set; } = cancellationToken; - - /// - /// Gets the output path for deployment artifacts. - /// - public string? OutputPath { get; } = outputPath; } diff --git a/src/Aspire.Hosting/Pipelines/PipelineOutputService.cs b/src/Aspire.Hosting/Pipelines/PipelineOutputService.cs new file mode 100644 index 00000000000..4d3eccec412 --- /dev/null +++ b/src/Aspire.Hosting/Pipelines/PipelineOutputService.cs @@ -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; + +/// +/// Default implementation of . +/// +[Experimental("ASPIREPIPELINES004", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] +internal sealed class PipelineOutputService : IPipelineOutputService +{ + /// + /// Stores the resolved output directory path, or null if not specified. + /// + private readonly string? _outputPath; + + /// + /// Lazily creates and stores the path to the temporary directory for pipeline output. + /// + private readonly Lazy _tempDirectory; + + public PipelineOutputService(IOptions options, IConfiguration configuration) + { + _outputPath = options.Value.OutputPath is not null ? Path.GetFullPath(options.Value.OutputPath) : null; + _tempDirectory = new Lazy(() => CreateTempDirectory(configuration)); + } + + /// + public string GetOutputDirectory() + { + return _outputPath ?? Path.Combine(Environment.CurrentDirectory, "aspire-output"); + } + + /// + public string GetOutputDirectory(IResource resource) + { + ArgumentNullException.ThrowIfNull(resource); + + var baseOutputDir = GetOutputDirectory(); + return Path.Combine(baseOutputDir, resource.Name); + } + + /// + public string GetTempDirectory() + { + return _tempDirectory.Value; + } + + /// + public string GetTempDirectory(IResource resource) + { + ArgumentNullException.ThrowIfNull(resource); + + var baseTempDir = GetTempDirectory(); + return Path.Combine(baseTempDir, resource.Name); + } + + /// + /// 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. + /// + 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; + } +} diff --git a/src/Aspire.Hosting/Pipelines/PipelineStepContext.cs b/src/Aspire.Hosting/Pipelines/PipelineStepContext.cs index 9eab4e0af15..d3d7faa47fd 100644 --- a/src/Aspire.Hosting/Pipelines/PipelineStepContext.cs +++ b/src/Aspire.Hosting/Pipelines/PipelineStepContext.cs @@ -54,9 +54,4 @@ public sealed class PipelineStepContext /// Gets the cancellation token for the pipeline operation. /// public CancellationToken CancellationToken => PipelineContext.CancellationToken; - - /// - /// Gets the output path for deployment artifacts. - /// - public string? OutputPath => PipelineContext.OutputPath; } \ No newline at end of file diff --git a/src/Aspire.Hosting/Publishing/PipelineExecutor.cs b/src/Aspire.Hosting/Publishing/PipelineExecutor.cs index 24b2aa5fad6..21a41c2d801 100644 --- a/src/Aspire.Hosting/Publishing/PipelineExecutor.cs +++ b/src/Aspire.Hosting/Publishing/PipelineExecutor.cs @@ -12,7 +12,6 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; namespace Aspire.Hosting.Publishing; @@ -25,7 +24,6 @@ internal sealed class PipelineExecutor( IPipelineActivityReporter activityReporter, IDistributedApplicationEventing eventing, BackchannelService backchannelService, - IOptions options, IPipelineActivityReporter pipelineActivityReporter) : BackgroundService { protected override async Task ExecuteAsync(CancellationToken stoppingToken) @@ -99,8 +97,7 @@ await eventing.PublishAsync( 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(); await pipeline.ExecuteAsync(pipelineContext).ConfigureAwait(false); diff --git a/src/Shared/PublishingContextUtils.cs b/src/Shared/PublishingContextUtils.cs index d358a0025bb..185aa207c17 100644 --- a/src/Shared/PublishingContextUtils.cs +++ b/src/Shared/PublishingContextUtils.cs @@ -2,9 +2,11 @@ // 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; @@ -12,13 +14,14 @@ internal static class PublishingContextUtils { public static string GetEnvironmentOutputPath(PipelineStepContext context, IComputeEnvironmentResource environment) { + var outputService = context.Services.GetRequiredService(); if (context.Model.Resources.OfType().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(); } } diff --git a/tests/Aspire.Hosting.Tests/Helpers/JsonDocumentManifestPublisher.cs b/tests/Aspire.Hosting.Tests/Helpers/JsonDocumentManifestPublisher.cs index 4ceb1cd9ab9..c886c180dd1 100644 --- a/tests/Aspire.Hosting.Tests/Helpers/JsonDocumentManifestPublisher.cs +++ b/tests/Aspire.Hosting.Tests/Helpers/JsonDocumentManifestPublisher.cs @@ -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; @@ -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(); + var manifestPath = outputService.GetOutputDirectory(); var publishingContext = new ManifestPublishingContext(executionContext, manifestPath, writer, context.CancellationToken); await publishingContext.WriteModel(context.Model, context.CancellationToken).ConfigureAwait(false); diff --git a/tests/Aspire.Hosting.Tests/Pipelines/DistributedApplicationPipelineTests.cs b/tests/Aspire.Hosting.Tests/Pipelines/DistributedApplicationPipelineTests.cs index 5322c0e6c4f..dd8c1a89fcf 100644 --- a/tests/Aspire.Hosting.Tests/Pipelines/DistributedApplicationPipelineTests.cs +++ b/tests/Aspire.Hosting.Tests/Pipelines/DistributedApplicationPipelineTests.cs @@ -876,8 +876,7 @@ private static PipelineContext CreateDeployingContext(DistributedApplication app app.Services.GetRequiredService(), app.Services, NullLogger.Instance, - CancellationToken.None, - outputPath: null); + CancellationToken.None); } [Fact]