diff --git a/src/Aspire.Cli/Backchannel/AppHostCliBackchannel.cs b/src/Aspire.Cli/Backchannel/AppHostCliBackchannel.cs index fc1d57d32ab..05dea860840 100644 --- a/src/Aspire.Cli/Backchannel/AppHostCliBackchannel.cs +++ b/src/Aspire.Cli/Backchannel/AppHostCliBackchannel.cs @@ -27,6 +27,8 @@ internal interface IAppHostCliBackchannel Task CompletePromptResponseAsync(string promptId, PublishingPromptInputAnswer[] answers, CancellationToken cancellationToken); Task UpdatePromptResponseAsync(string promptId, PublishingPromptInputAnswer[] answers, CancellationToken cancellationToken); Task GetPipelineStepsAsync(string? step, CancellationToken cancellationToken); + Task GetPipelineOutputsAsync(CancellationToken cancellationToken); + Task AuthorizePipelineExecutionAsync(CancellationToken cancellationToken); Task UploadFileAsync(string filePath, string fileName, CancellationToken cancellationToken); } @@ -554,6 +556,44 @@ public async Task GetPipelineStepsAsync(string? step, return response; } + public async Task GetPipelineOutputsAsync(CancellationToken cancellationToken) + { + using var activity = telemetry.StartDiagnosticActivity(); + var rpc = await GetRpcTaskAsync().WaitAsync(cancellationToken).ConfigureAwait(false); + + logger.LogDebug("Requesting pipeline outputs."); + + var response = await rpc.InvokeWithProfilingAsync( + profilingTelemetry, + "apphost", + "GetPipelineOutputsAsync", + [new GetPipelineOutputsRequest()], + cancellationToken).ConfigureAwait(false); + + logger.LogDebug("Received {OutputCount} pipeline outputs.", response.Outputs.Length); + return response; + } + + public async Task AuthorizePipelineExecutionAsync(CancellationToken cancellationToken) + { + using var activity = telemetry.StartDiagnosticActivity(); + var rpc = await GetRpcTaskAsync().WaitAsync(cancellationToken).ConfigureAwait(false); + + logger.LogDebug("Authorizing pipeline execution."); + + var response = await rpc.InvokeWithProfilingAsync( + profilingTelemetry, + "apphost", + "AuthorizePipelineExecutionAsync", + [new AuthorizePipelineExecutionRequest()], + cancellationToken).ConfigureAwait(false); + + if (!response.IsAuthorized) + { + throw new InvalidOperationException("The AppHost did not authorize pipeline execution."); + } + } + public async Task UploadFileAsync(string filePath, string fileName, CancellationToken cancellationToken) { using var activity = telemetry.StartDiagnosticActivity(); diff --git a/src/Aspire.Cli/Backchannel/BackchannelJsonSerializerContext.cs b/src/Aspire.Cli/Backchannel/BackchannelJsonSerializerContext.cs index e5a224f8bfa..7260a804c2a 100644 --- a/src/Aspire.Cli/Backchannel/BackchannelJsonSerializerContext.cs +++ b/src/Aspire.Cli/Backchannel/BackchannelJsonSerializerContext.cs @@ -95,6 +95,14 @@ namespace Aspire.Cli.Backchannel; [JsonSerializable(typeof(PipelineStepInfo[]))] [JsonSerializable(typeof(GetPipelineStepsRequest))] [JsonSerializable(typeof(GetPipelineStepsResponse))] +[JsonSerializable(typeof(PipelineOutputInfo))] +[JsonSerializable(typeof(PipelineOutputInfo[]))] +[JsonSerializable(typeof(PipelineOutputStepInfo))] +[JsonSerializable(typeof(PipelineOutputStepInfo[]))] +[JsonSerializable(typeof(GetPipelineOutputsRequest))] +[JsonSerializable(typeof(GetPipelineOutputsResponse))] +[JsonSerializable(typeof(AuthorizePipelineExecutionRequest))] +[JsonSerializable(typeof(AuthorizePipelineExecutionResponse))] [JsonSerializable(typeof(UploadFileRequest))] [JsonSerializable(typeof(UploadFileResponse))] [JsonSerializable(typeof(FileReferenceDto[]))] diff --git a/src/Aspire.Hosting.Azure.AppService/AzureAppServiceEnvironmentResource.cs b/src/Aspire.Hosting.Azure.AppService/AzureAppServiceEnvironmentResource.cs index f21cfe6907d..2b030a79d26 100644 --- a/src/Aspire.Hosting.Azure.AppService/AzureAppServiceEnvironmentResource.cs +++ b/src/Aspire.Hosting.Azure.AppService/AzureAppServiceEnvironmentResource.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. #pragma warning disable ASPIREPIPELINES001 +#pragma warning disable ASPIREPIPELINES004 #pragma warning disable ASPIREAZURE001 using System.Diagnostics.CodeAnalysis; @@ -71,6 +72,7 @@ public AzureAppServiceEnvironmentResource(string name, Action ValidateAppServiceConfigurationAsync(ctx, model), RequiredBySteps = [WellKnownPipelineSteps.Publish], DependsOnSteps = [WellKnownPipelineSteps.PublishPrereq] diff --git a/src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs b/src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs index 08e52e101ae..70d7f079e1e 100644 --- a/src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs +++ b/src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs @@ -94,6 +94,7 @@ public AzureEnvironmentResource(string name, ParameterResource location, Paramet { Name = $"publish-{Name}", Description = $"Publishes the Azure environment configuration for {Name}.", + SupportsOutputPathRelocation = true, Action = ctx => PublishAsync(ctx), RequiredBySteps = [WellKnownPipelineSteps.Publish], DependsOnSteps = [WellKnownPipelineSteps.PublishPrereq] @@ -184,9 +185,8 @@ private static void AddToPipelineSummary(PipelineStepContext ctx, ProvisioningCo private Task PublishAsync(PipelineStepContext context) { var azureProvisioningOptions = context.Services.GetRequiredService>(); - var outputService = context.Services.GetRequiredService(); var publishingContext = new AzurePublishingContext( - outputService.GetOutputDirectory(), + context.Outputs.PrimaryOutput.OutputPath, azureProvisioningOptions.Value, context.Services, context.Logger, diff --git a/src/Aspire.Hosting.Docker/DockerComposeEnvironmentResource.cs b/src/Aspire.Hosting.Docker/DockerComposeEnvironmentResource.cs index b54f2754bd9..03bdec98bf5 100644 --- a/src/Aspire.Hosting.Docker/DockerComposeEnvironmentResource.cs +++ b/src/Aspire.Hosting.Docker/DockerComposeEnvironmentResource.cs @@ -4,6 +4,7 @@ #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 ASPIREPIPELINES002 #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 ASPIREPIPELINES004 #pragma warning disable ASPIRECONTAINERRUNTIME001 using System.Diagnostics.CodeAnalysis; @@ -78,6 +79,7 @@ public DockerComposeEnvironmentResource(string name) : base(name) { Name = $"publish-{Name}", Description = $"Publishes the Docker Compose environment configuration for {Name}.", + SupportsOutputPathRelocation = true, Action = ctx => PublishAsync(ctx) }; publishStep.RequiredBy(WellKnownPipelineSteps.Publish); diff --git a/src/Aspire.Hosting.EntityFrameworkCore/EFMigrationResourceBuilderExtensions.cs b/src/Aspire.Hosting.EntityFrameworkCore/EFMigrationResourceBuilderExtensions.cs index fd5f70c5d18..2341dfd9197 100644 --- a/src/Aspire.Hosting.EntityFrameworkCore/EFMigrationResourceBuilderExtensions.cs +++ b/src/Aspire.Hosting.EntityFrameworkCore/EFMigrationResourceBuilderExtensions.cs @@ -346,7 +346,7 @@ private static void ConfigureBundleContainer(IResourceBuilder builder) { var configured = builder.ApplicationBuilder.Configuration["Pipeline:OutputPath"]; diff --git a/src/Aspire.Hosting.EntityFrameworkCore/EFResourceBuilderExtensions.cs b/src/Aspire.Hosting.EntityFrameworkCore/EFResourceBuilderExtensions.cs index fc125197c99..54fc0fc3a0d 100644 --- a/src/Aspire.Hosting.EntityFrameworkCore/EFResourceBuilderExtensions.cs +++ b/src/Aspire.Hosting.EntityFrameworkCore/EFResourceBuilderExtensions.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. #pragma warning disable ASPIREPIPELINES001 +#pragma warning disable ASPIREPIPELINES004 #pragma warning disable ASPIREDOTNETTOOL using Aspire.Hosting.ApplicationModel; @@ -244,6 +245,7 @@ internal static IEnumerable CreateMigrationPipelineStep(PipelineSt Name = scriptStepName!, Description = $"Generate EF Core migration SQL script for {migrationResource.Name}", Resource = migrationResource, + SupportsOutputPathRelocation = true, DependsOnSteps = scriptDependsOn, RequiredBySteps = [WellKnownPipelineSteps.Publish], Action = stepContext => ExecutePublishPipelineOperationAsync( @@ -288,6 +290,7 @@ internal static IEnumerable CreateMigrationPipelineStep(PipelineSt Name = bundleStepName!, Description = $"Generate EF Core migration bundle for {migrationResource.Name}", Resource = migrationResource, + SupportsOutputPathRelocation = true, DependsOnSteps = bundleDependsOn, RequiredBySteps = requiredBy, Action = stepContext => ExecutePublishPipelineOperationAsync( @@ -356,9 +359,6 @@ private static async Task ExecutePublishPipelineOperationAsync( Func> executeOperation) { var logger = stepContext.Logger; -#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. - var pipelineOutputService = stepContext.Services.GetRequiredService(); -#pragma warning restore ASPIREPIPELINES004 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. using var executor = new EFCoreOperationExecutor( migrationResource.ProjectResource, @@ -369,7 +369,7 @@ private static async Task ExecutePublishPipelineOperationAsync( stepContext.Services, migrationResource.ToolResource); - var outputDir = Path.Combine(pipelineOutputService.GetOutputDirectory(), "efmigrations"); + var outputDir = Path.Combine(stepContext.Outputs.PrimaryOutput.OutputPath, "efmigrations"); Directory.CreateDirectory(outputDir); logger.LogInformation("Generating {Operation} for '{ResourceName}'...", operationName, migrationResource.Name); diff --git a/src/Aspire.Hosting.JavaScript/JavaScriptHostingExtensions.cs b/src/Aspire.Hosting.JavaScript/JavaScriptHostingExtensions.cs index 0a48cbcc150..10319899af7 100644 --- a/src/Aspire.Hosting.JavaScript/JavaScriptHostingExtensions.cs +++ b/src/Aspire.Hosting.JavaScript/JavaScriptHostingExtensions.cs @@ -3,6 +3,7 @@ #pragma warning disable ASPIREDOCKERFILEBUILDER001 #pragma warning disable ASPIREPIPELINES001 +#pragma warning disable ASPIREPIPELINES004 #pragma warning disable ASPIRECERTIFICATES001 #pragma warning disable ASPIREEXTENSION001 #pragma warning disable ASPIRECOMMAND001 @@ -1366,6 +1367,7 @@ Task WriteValidatedContainerAsync(ManifestPublishingContext context) { Name = validationStepName, Description = $"Validates that JavaScript app '{resource.Name}' does not publish an ignored run script with an existing Dockerfile.", + SupportsOutputPathRelocation = true, RequiredBySteps = [WellKnownPipelineSteps.Build, WellKnownPipelineSteps.Publish], Resource = containerBuilder.Resource, Action = _ => diff --git a/src/Aspire.Hosting.Kubernetes/KubernetesEnvironmentResource.cs b/src/Aspire.Hosting.Kubernetes/KubernetesEnvironmentResource.cs index b63ada50ebe..f200b45dcf2 100644 --- a/src/Aspire.Hosting.Kubernetes/KubernetesEnvironmentResource.cs +++ b/src/Aspire.Hosting.Kubernetes/KubernetesEnvironmentResource.cs @@ -2,6 +2,7 @@ // 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 #pragma warning disable ASPIRECOMPUTE002 // 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; @@ -231,6 +232,7 @@ public KubernetesEnvironmentResource(string name) : base(name) { Name = $"publish-{Name}", Description = $"Publishes the Kubernetes environment configuration for {Name}.", + SupportsOutputPathRelocation = true, Action = ctx => PublishAsync(ctx) }; // Depend on publish-prereq so that process-parameters has run before we diff --git a/src/Aspire.Hosting.Radius/Publishing/RadiusBicepPublishingContext.cs b/src/Aspire.Hosting.Radius/Publishing/RadiusBicepPublishingContext.cs index 0dd5c0c360b..ffb85294cce 100644 --- a/src/Aspire.Hosting.Radius/Publishing/RadiusBicepPublishingContext.cs +++ b/src/Aspire.Hosting.Radius/Publishing/RadiusBicepPublishingContext.cs @@ -41,6 +41,7 @@ internal PipelineStep CreatePipelineStep() { Name = $"publish-radius-{_environment.Name}", Description = $"Publish Radius environment '{_environment.Name}' as Bicep", + SupportsOutputPathRelocation = true, Action = ExecuteAsync }; step.RequiredBy(WellKnownPipelineSteps.Publish); diff --git a/src/Aspire.Hosting/Backchannel/AppHostRpcTarget.cs b/src/Aspire.Hosting/Backchannel/AppHostRpcTarget.cs index c73448cc247..f31b8926338 100644 --- a/src/Aspire.Hosting/Backchannel/AppHostRpcTarget.cs +++ b/src/Aspire.Hosting/Backchannel/AppHostRpcTarget.cs @@ -229,7 +229,8 @@ public Task GetCapabilitiesAsync(CancellationToken cancellationToken) _ = cancellationToken; return Task.FromResult(new string[] { "baseline.v2", - "pipeline-steps.v1" + "pipeline-steps.v1", + "pipeline-outputs.v1" }); } #pragma warning restore CA1822 @@ -323,4 +324,59 @@ public async Task GetPipelineStepsAsync(GetPipelineSte }).ToArray() }; } + + public async Task GetPipelineOutputsAsync( + GetPipelineOutputsRequest? request = null, + CancellationToken cancellationToken = default) + { + using var activity = profilingTelemetry.StartJsonRpcServerCall( + nameof(GetPipelineOutputsAsync), + streaming: false, + request?.TraceContext); +#pragma warning disable ASPIREPIPELINES004 + var registry = serviceProvider.GetRequiredService(); + await registry.WaitForPreparationAsync(cancellationToken).ConfigureAwait(false); + var outputs = registry.GetOutputs(); + var steps = registry.GetSelectedSteps(); + + return new GetPipelineOutputsResponse + { + AppHostDirectory = registry.AppHostDirectory, + State = registry.GetExecutionState().ToString(), + Steps = steps.Select(step => new PipelineOutputStepInfo + { + Name = step.Name, + SupportsOutputPathRelocation = registry.SupportsOutputPathRelocation(step) + }).ToArray(), + Outputs = outputs.Select(output => new PipelineOutputInfo + { + IsPrimary = output.IsPrimary, + PublisherName = output.PublisherName, + Name = output.Name, + Kind = output.Kind.ToString(), + OutputPath = output.OutputPath, + LogicalTargetPath = output.LogicalTargetPath + }).ToArray() + }; +#pragma warning restore ASPIREPIPELINES004 + } + + public Task AuthorizePipelineExecutionAsync( + AuthorizePipelineExecutionRequest? request = null, + CancellationToken cancellationToken = default) + { + using var activity = profilingTelemetry.StartJsonRpcServerCall( + nameof(AuthorizePipelineExecutionAsync), + streaming: false, + request?.TraceContext); + _ = cancellationToken; + + var registry = serviceProvider.GetRequiredService(); + registry.AuthorizeExecution(); + + return Task.FromResult(new AuthorizePipelineExecutionResponse + { + IsAuthorized = true + }); + } } diff --git a/src/Aspire.Hosting/Backchannel/BackchannelDataTypes.cs b/src/Aspire.Hosting/Backchannel/BackchannelDataTypes.cs index 881fcb660f1..3f0a6ac1d60 100644 --- a/src/Aspire.Hosting/Backchannel/BackchannelDataTypes.cs +++ b/src/Aspire.Hosting/Backchannel/BackchannelDataTypes.cs @@ -1044,6 +1044,119 @@ internal sealed class GetPipelineStepsResponse public required PipelineStepInfo[] Steps { get; init; } } +/// +/// Represents a resolved pipeline output for CLI reconciliation. +/// +internal sealed class PipelineOutputInfo +{ + /// + /// Gets a value indicating whether this is the shared primary pipeline output. + /// + public required bool IsPrimary { get; init; } + + /// + /// Gets the pipeline-step name that owns the output. + /// + public required string PublisherName { get; init; } + + /// + /// Gets the name of the output. + /// + public required string Name { get; init; } + + /// + /// Gets the output kind. + /// + public required string Kind { get; init; } + + /// + /// Gets the path where the pipeline wrote the output. + /// + public required string OutputPath { get; init; } + + /// + /// Gets the logical target path for the output. + /// + public required string LogicalTargetPath { get; init; } +} + +/// +/// Represents output relocation support for a selected pipeline step. +/// +internal sealed class PipelineOutputStepInfo +{ + /// + /// Gets the pipeline-step name. + /// + public required string Name { get; init; } + + /// + /// Gets a value indicating whether the step supports output-path relocation. + /// + public bool SupportsOutputPathRelocation { get; init; } +} + +/// +/// Request for getting resolved pipeline outputs. +/// +internal sealed class GetPipelineOutputsRequest : BackchannelRequest +{ + /// + public override GetPipelineOutputsRequest WithTraceContext(BackchannelTraceContext traceContext) => new() + { + TraceContext = traceContext + }; +} + +/// +/// Response containing resolved pipeline outputs. +/// +internal sealed class GetPipelineOutputsResponse +{ + /// + /// Gets the fully qualified AppHost directory used as the logical publication root. + /// + public required string AppHostDirectory { get; init; } + + /// + /// Gets the output-plan execution state. + /// + public required string State { get; init; } + + /// + /// Gets the selected pipeline steps in execution order. + /// + public required PipelineOutputStepInfo[] Steps { get; init; } + + /// + /// Gets the resolved outputs in deterministic publisher/name order. + /// + public required PipelineOutputInfo[] Outputs { get; init; } +} + +/// +/// Request to authorize execution of a relocated pipeline output plan. +/// +internal sealed class AuthorizePipelineExecutionRequest : BackchannelRequest +{ + /// + public override AuthorizePipelineExecutionRequest WithTraceContext(BackchannelTraceContext traceContext) => new() + { + TraceContext = traceContext + }; +} + +/// +/// Response indicating that a relocated pipeline output plan was authorized. +/// +internal sealed class AuthorizePipelineExecutionResponse +{ + /// + /// Gets a value indicating whether execution was authorized. + /// + public bool IsAuthorized { get; init; } +} + /// /// Represents the connection information for the Dashboard MCP server. /// diff --git a/src/Aspire.Hosting/DistributedApplicationBuilder.cs b/src/Aspire.Hosting/DistributedApplicationBuilder.cs index 02f00deeda0..16a22b6b33f 100644 --- a/src/Aspire.Hosting/DistributedApplicationBuilder.cs +++ b/src/Aspire.Hosting/DistributedApplicationBuilder.cs @@ -554,7 +554,9 @@ public DistributedApplicationBuilder(DistributedApplicationOptions options) _innerBuilder.Services.AddSingleton(); _innerBuilder.Services.AddSingleton(sp => sp.GetRequiredService()); _innerBuilder.Services.AddSingleton(); - _innerBuilder.Services.AddSingleton(Pipeline); + _innerBuilder.Services.AddSingleton(); + _innerBuilder.Services.AddSingleton((DistributedApplicationPipeline)Pipeline); + _innerBuilder.Services.AddSingleton(sp => sp.GetRequiredService()); // Configure pipeline logging options _innerBuilder.Services.Configure(options => @@ -784,7 +786,7 @@ private void ConfigurePipelineOptions(DistributedApplicationOptions options) // If no explicit --step was provided, set it to run only the manifest step if (string.IsNullOrEmpty(_innerBuilder.Configuration["Pipeline:Step"])) { - _innerBuilder.Configuration["Pipeline:Step"] = "publish-manifest"; + _innerBuilder.Configuration["Pipeline:Step"] = WellKnownPipelineSteps.PublishManifest; } // If no explicit operation was set, default to Publish mode diff --git a/src/Aspire.Hosting/Pipelines/DistributedApplicationPipeline.cs b/src/Aspire.Hosting/Pipelines/DistributedApplicationPipeline.cs index 5ce9435b6c0..7e1c27e29c2 100644 --- a/src/Aspire.Hosting/Pipelines/DistributedApplicationPipeline.cs +++ b/src/Aspire.Hosting/Pipelines/DistributedApplicationPipeline.cs @@ -6,6 +6,7 @@ #pragma warning disable ASPIREPIPELINES001 #pragma warning disable ASPIREPIPELINES002 #pragma warning disable ASPIREPIPELINES003 +#pragma warning disable ASPIREPIPELINES004 using System.Diagnostics; using System.Globalization; @@ -48,6 +49,7 @@ public DistributedApplicationPipeline() { Name = WellKnownPipelineSteps.ProcessParameters, Description = "Prompts for parameter values before build, publish, or deployment operations.", + SupportsOutputPathRelocation = true, Action = async context => { // Parameter processing - ensure all parameters are initialized and resolved @@ -301,6 +303,7 @@ public DistributedApplicationPipeline() { Name = WellKnownPipelineSteps.Publish, Description = "Aggregation step for all publish operations. All publish steps should be required by this step.", + SupportsOutputPathRelocation = true, Action = _ => Task.CompletedTask }); @@ -308,6 +311,7 @@ public DistributedApplicationPipeline() { Name = WellKnownPipelineSteps.PublishPrereq, Description = "Prerequisite step that runs before any publish operations.", + SupportsOutputPathRelocation = true, Action = _ => Task.CompletedTask, }); @@ -315,6 +319,7 @@ public DistributedApplicationPipeline() { Name = ValidateBuildOnlyContainerReferencesStepName, Description = "Validates that build-only containers are consumed by another resource before publish or deploy.", + SupportsOutputPathRelocation = true, Action = static context => { ValidateBuildOnlyContainerReferences(context.Model); @@ -571,18 +576,36 @@ internal DistributedApplicationPipeline Clone() } public async Task ExecuteAsync(PipelineContext context) + { + var executionPlan = await PrepareAsync(context).ConfigureAwait(false); + await context.Services.GetRequiredService() + .WaitForExecutionAuthorizationAsync(context.CancellationToken) + .ConfigureAwait(false); + await ExecuteAsync(executionPlan, context).ConfigureAwait(false); + } + + internal async Task PrepareAsync(PipelineContext context) { var allSteps = await ResolveStepsAsync(context).ConfigureAwait(false); - if (allSteps.Count == 0) + var (selectedSteps, _) = FilterStepsForExecution(allSteps, context); + var stepsToExecute = selectedSteps.Select(step => step.Clone()).ToList(); + var stepsByName = stepsToExecute.ToDictionary(step => step.Name, StringComparer.Ordinal); + var orderedSteps = GetTopologicalOrder(stepsToExecute); + context.Services.GetRequiredService().Prepare(orderedSteps); + + return new PipelineExecutionPlan(stepsToExecute, stepsByName); + } + + internal static async Task ExecuteAsync(PipelineExecutionPlan executionPlan, PipelineContext context) + { + if (executionPlan.Steps.Count == 0) { return; } - var (stepsToExecute, stepsByName) = FilterStepsForExecution(allSteps, context); - // Build dependency graph and execute with readiness-based scheduler - await ExecuteStepsAsTaskDag(stepsToExecute, stepsByName, context).ConfigureAwait(false); + await ExecuteStepsAsTaskDag(executionPlan.Steps, executionPlan.StepsByName, context).ConfigureAwait(false); } /// @@ -903,7 +926,8 @@ async Task ExecuteStepWithDependencies(PipelineStep step) var stepContext = new PipelineStepContext { PipelineContext = context, - ReportingStep = reportingStep + ReportingStep = reportingStep, + Outputs = new PipelineStepOutputResolver(context.Services, step) }; try @@ -1014,7 +1038,8 @@ private static async Task ExecuteStepsSequentially( var stepContext = new PipelineStepContext { PipelineContext = context, - ReportingStep = reportingStep + ReportingStep = reportingStep, + Outputs = new PipelineStepOutputResolver(context.Services, step) }; try @@ -1533,4 +1558,13 @@ public override string ToString() return sb.ToString(); } + + internal sealed class PipelineExecutionPlan( + List steps, + Dictionary stepsByName) + { + public List Steps { get; } = steps; + + public Dictionary StepsByName { get; } = stepsByName; + } } diff --git a/src/Aspire.Hosting/Pipelines/IPipelineOutputResolver.cs b/src/Aspire.Hosting/Pipelines/IPipelineOutputResolver.cs new file mode 100644 index 00000000000..c6525693151 --- /dev/null +++ b/src/Aspire.Hosting/Pipelines/IPipelineOutputResolver.cs @@ -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; + +namespace Aspire.Hosting.Pipelines; + +/// +/// Resolves output paths declared by the currently executing pipeline step. +/// +[Experimental("ASPIREPIPELINES004", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] +public interface IPipelineOutputResolver +{ + /// + /// Gets the fully qualified AppHost project directory used to resolve relative output paths. + /// + string AppHostDirectory { get; } + + /// + /// Gets the primary pipeline output. + /// + /// + /// The output path is the only path to which the pipeline step should write. The logical + /// target path identifies where the output persists outside a relocated pipeline execution. + /// + ResolvedPipelineOutput PrimaryOutput { get; } + + /// + /// Resolves a declared output for the current pipeline step. + /// + /// The output definition attached to the current pipeline step. + /// The resolved output paths. + /// + /// Thrown when the definition is not declared by the current pipeline step. + /// + /// + /// Thrown when is . + /// + ResolvedPipelineOutput Resolve(PipelineOutputDefinition definition); +} diff --git a/src/Aspire.Hosting/Pipelines/PipelineOutputDefinition.cs b/src/Aspire.Hosting/Pipelines/PipelineOutputDefinition.cs new file mode 100644 index 00000000000..30c32a485a7 --- /dev/null +++ b/src/Aspire.Hosting/Pipelines/PipelineOutputDefinition.cs @@ -0,0 +1,103 @@ +// 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; + +namespace Aspire.Hosting.Pipelines; + +/// +/// Declares a named output produced by a pipeline step. +/// +/// +/// Relative paths are resolved from the AppHost project directory. Pipeline steps must write +/// artifacts to the path returned by . +/// +/// +/// This example declares and resolves an inventory directory: +/// +/// var inventoryDefinition = new PipelineOutputDefinition( +/// "inventory", +/// ".configgen", +/// PipelineOutputKind.Directory); +/// +/// var step = new PipelineStep +/// { +/// Name = "generate-inventory", +/// Outputs = [inventoryDefinition], +/// SupportsOutputPathRelocation = true, +/// Action = context => +/// { +/// var inventory = context.Outputs.Resolve(inventoryDefinition); +/// Directory.CreateDirectory(inventory.OutputPath); +/// File.WriteAllText(Path.Combine(inventory.OutputPath, "inventory.txt"), "generated"); +/// return Task.CompletedTask; +/// } +/// }; +/// +/// +[Experimental("ASPIREPIPELINES004", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] +public sealed class PipelineOutputDefinition +{ + /// + /// Initializes a new instance of the class. + /// + /// The name that uniquely identifies this output within its pipeline step. + /// The default logical target path. + /// The kind of output. + /// + /// Thrown when or is . + /// + /// + /// Thrown when is empty, contains a colon, or when + /// is empty or rooted. + /// + /// + /// Thrown when is not a defined value. + /// + public PipelineOutputDefinition(string name, string defaultPath, PipelineOutputKind kind) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + ArgumentException.ThrowIfNullOrWhiteSpace(defaultPath); + + if (name.Contains(':')) + { + throw new ArgumentException("Pipeline output names cannot contain ':'.", nameof(name)); + } + + if (Path.IsPathRooted(defaultPath)) + { + throw new ArgumentException( + "The default pipeline output path must be relative to the AppHost directory.", + nameof(defaultPath)); + } + + if (!Enum.IsDefined(kind)) + { + throw new ArgumentOutOfRangeException(nameof(kind)); + } + + Name = name; + DefaultPath = defaultPath; + Kind = kind; + } + + /// + /// Gets the name that uniquely identifies this output within its pipeline step. + /// + public string Name { get; } + + /// + /// Gets the default logical target path. + /// + /// + /// Relative paths are resolved from . + /// The configured path can be overridden through the + /// Pipeline:Outputs:<step-name>:<output-name>:Path configuration key. + /// + public string DefaultPath { get; } + + /// + /// Gets the kind of output. + /// + public PipelineOutputKind Kind { get; } +} diff --git a/src/Aspire.Hosting/Pipelines/PipelineOutputKind.cs b/src/Aspire.Hosting/Pipelines/PipelineOutputKind.cs new file mode 100644 index 00000000000..8028bd80363 --- /dev/null +++ b/src/Aspire.Hosting/Pipelines/PipelineOutputKind.cs @@ -0,0 +1,23 @@ +// 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; + +namespace Aspire.Hosting.Pipelines; + +/// +/// Identifies the kind of artifact produced by a pipeline output. +/// +[Experimental("ASPIREPIPELINES004", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] +public enum PipelineOutputKind +{ + /// + /// The output is a directory that can contain one or more artifacts. + /// + Directory = 0, + + /// + /// The output is a single file. + /// + File = 1, +} diff --git a/src/Aspire.Hosting/Pipelines/PipelineOutputRegistry.cs b/src/Aspire.Hosting/Pipelines/PipelineOutputRegistry.cs new file mode 100644 index 00000000000..e7e180c8798 --- /dev/null +++ b/src/Aspire.Hosting/Pipelines/PipelineOutputRegistry.cs @@ -0,0 +1,586 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#pragma warning disable ASPIREPIPELINES004 +#pragma warning disable ASPIREPIPELINES001 + +using System.IO.Hashing; +using System.Runtime.ExceptionServices; +using System.Text; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace Aspire.Hosting.Pipelines; + +internal sealed class PipelineOutputRegistry +{ + internal const string PrimaryPublisherName = "aspire"; + internal const string PrimaryOutputName = "primary"; + internal const string StagingPathConfigurationKey = "Pipeline:Verification:StagingPath"; + internal const string TargetOutputPathConfigurationKey = "Pipeline:Verification:TargetOutputPath"; + internal const int MaximumStagedOutputNameByteCount = 200; + private const int MaximumStagedOutputExtensionByteCount = 32; + + private readonly object _lock = new(); + private readonly IConfiguration _configuration; + private readonly string? _stagingPath; + private readonly Dictionary _outputs = []; + private readonly ResolvedPipelineOutput _primaryOutput; + private readonly TaskCompletionSource _preparationCompleted = new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly TaskCompletionSource _executionAuthorized = new(TaskCreationOptions.RunContinuationsAsynchronously); + private IReadOnlyList _selectedSteps = []; + private Exception? _preparationException; + private PipelineOutputExecutionState _executionState = PipelineOutputExecutionState.Preparing; + private bool _prepared; + + public PipelineOutputRegistry( + IConfiguration configuration, + IPipelineOutputService outputService, + IOptions pipelineOptions) + { + _configuration = configuration; + + var configuredAppHostDirectory = configuration["AppHost:Directory"]; + if (string.IsNullOrWhiteSpace(configuredAppHostDirectory)) + { + throw new InvalidOperationException("The AppHost directory is not available."); + } + + AppHostDirectory = Path.GetFullPath(configuredAppHostDirectory); + + var configuredStagingPath = configuration[StagingPathConfigurationKey]; + var configuredPrimaryTargetPath = configuration[TargetOutputPathConfigurationKey]; + var hasStagingPath = !string.IsNullOrWhiteSpace(configuredStagingPath); + var hasPrimaryTargetPath = !string.IsNullOrWhiteSpace(configuredPrimaryTargetPath); + if (hasStagingPath != hasPrimaryTargetPath) + { + throw new InvalidOperationException( + $"Configuration values '{StagingPathConfigurationKey}' and '{TargetOutputPathConfigurationKey}' must be specified together."); + } + + _stagingPath = NormalizeOptionalPath(configuredStagingPath, AppHostDirectory); + + var primaryOutputPath = Path.GetFullPath(outputService.GetOutputDirectory()); + var primaryTargetPath = NormalizeOptionalPath(configuredPrimaryTargetPath, AppHostDirectory) + ?? primaryOutputPath; + // Direct manifest publishing historically accepts a .json file path. Every other + // publisher treats the primary output as a directory, regardless of its suffix. + var primaryKind = + string.Equals(pipelineOptions.Value.Step, WellKnownPipelineSteps.PublishManifest, StringComparison.Ordinal) && + Path.GetExtension(primaryTargetPath).Equals(".json", StringComparison.OrdinalIgnoreCase) + ? PipelineOutputKind.File + : PipelineOutputKind.Directory; + ValidateTargetKind(PrimaryPublisherName, PrimaryOutputName, primaryTargetPath, primaryKind); + + _primaryOutput = new ResolvedPipelineOutput( + PrimaryPublisherName, + PrimaryOutputName, + primaryKind, + primaryOutputPath, + primaryTargetPath, + isPrimary: true); + } + + public string AppHostDirectory { get; } + + public bool RequiresExecutionAuthorization => _stagingPath is not null; + + public void Prepare(IEnumerable steps) + { + ArgumentNullException.ThrowIfNull(steps); + + lock (_lock) + { + if (_preparationCompleted.Task.IsCompleted) + { + throw new InvalidOperationException("The pipeline output plan has already been prepared."); + } + + try + { + var selectedSteps = steps.ToArray(); + foreach (var step in selectedSteps.OrderBy(step => step.Name, StringComparer.Ordinal)) + { + foreach (var definition in step.Outputs.OrderBy(output => output.Name, StringComparer.Ordinal)) + { + Register(step.Name, definition); + } + } + + ValidateExclusiveOwnership(); + _selectedSteps = selectedSteps; + _prepared = true; + _executionState = PipelineOutputExecutionState.Prepared; + } + catch (Exception ex) + { + _preparationException = ex; + _executionState = PipelineOutputExecutionState.Failed; + throw; + } + finally + { + _preparationCompleted.TrySetResult(); + } + } + } + + public async Task WaitForPreparationAsync(CancellationToken cancellationToken) + { + await _preparationCompleted.Task.WaitAsync(cancellationToken).ConfigureAwait(false); + + Exception? preparationException; + lock (_lock) + { + preparationException = _preparationException; + } + + if (preparationException is not null) + { + ExceptionDispatchInfo.Capture(preparationException).Throw(); + } + } + + public async Task WaitForExecutionAuthorizationAsync(CancellationToken cancellationToken) + { + await WaitForPreparationAsync(cancellationToken).ConfigureAwait(false); + + if (RequiresExecutionAuthorization) + { + await _executionAuthorized.Task.WaitAsync(cancellationToken).ConfigureAwait(false); + } + } + + public void AuthorizeExecution() + { + lock (_lock) + { + EnsurePrepared(); + + if (!RequiresExecutionAuthorization) + { + throw new InvalidOperationException("Pipeline execution authorization is only available for relocated output plans."); + } + + var unsupportedSteps = _selectedSteps + .Where(step => !SupportsOutputPathRelocationCore(step)) + .Select(step => step.Name) + .ToArray(); + if (unsupportedSteps.Length > 0) + { + throw new InvalidOperationException( + $"Pipeline execution cannot be authorized because these selected steps do not support output-path relocation: " + + $"{string.Join(", ", unsupportedSteps.Select(name => $"'{name}'"))}."); + } + + _executionAuthorized.TrySetResult(); + } + } + + public void MarkExecutionSucceeded() + { + lock (_lock) + { + EnsurePrepared(); + _executionState = PipelineOutputExecutionState.Succeeded; + } + } + + public void MarkExecutionFailed(Exception exception) + { + ArgumentNullException.ThrowIfNull(exception); + + lock (_lock) + { + _executionState = PipelineOutputExecutionState.Failed; + if (!_preparationCompleted.Task.IsCompleted) + { + _preparationException = exception; + _preparationCompleted.TrySetResult(); + } + } + } + + public PipelineOutputExecutionState GetExecutionState() + { + lock (_lock) + { + return _executionState; + } + } + + public ResolvedPipelineOutput GetPrimaryOutput() + { + lock (_lock) + { + EnsurePrepared(); + return _primaryOutput; + } + } + + public ResolvedPipelineOutput Resolve(PipelineStep step, PipelineOutputDefinition definition) + { + ArgumentNullException.ThrowIfNull(step); + ArgumentNullException.ThrowIfNull(definition); + + if (!step.Outputs.Contains(definition)) + { + throw new InvalidOperationException( + $"Pipeline output '{definition.Name}' is not declared by step '{step.Name}'."); + } + + lock (_lock) + { + EnsurePrepared(); + + var key = new OutputKey(step.Name, definition.Name); + if (!_outputs.TryGetValue(key, out var output)) + { + throw new InvalidOperationException( + $"Pipeline output '{definition.Name}' for step '{step.Name}' was not prepared before execution."); + } + + return output; + } + } + + public IReadOnlyList GetOutputs() + { + lock (_lock) + { + EnsurePrepared(); + + return _outputs.Values + .Append(_primaryOutput) + .OrderBy(output => output.PublisherName, StringComparer.Ordinal) + .ThenBy(output => output.Name, StringComparer.Ordinal) + .ThenByDescending(output => output.IsPrimary) + .ToArray(); + } + } + + public IReadOnlyList GetSelectedSteps() + { + lock (_lock) + { + EnsurePrepared(); + return _selectedSteps; + } + } + + public bool SupportsOutputPathRelocation(PipelineStep step) + { + ArgumentNullException.ThrowIfNull(step); + + lock (_lock) + { + EnsurePrepared(); + return SupportsOutputPathRelocationCore(step); + } + } + + private void Register(string publisherName, PipelineOutputDefinition definition) + { + ValidateIdentifier(publisherName, nameof(publisherName)); + ArgumentNullException.ThrowIfNull(definition); + ValidateIdentifier(definition.Name, nameof(definition.Name)); + + if (string.IsNullOrWhiteSpace(definition.DefaultPath)) + { + throw new ArgumentException("The pipeline output default path cannot be empty.", nameof(definition)); + } + + var key = new OutputKey(publisherName, definition.Name); + var configuredPath = _configuration[$"Pipeline:Outputs:{publisherName}:{definition.Name}:Path"]; + var targetPath = ResolveTargetPath(configuredPath ?? definition.DefaultPath); + ValidateTargetKind(publisherName, definition.Name, targetPath, definition.Kind); + var outputPath = _stagingPath is null + ? targetPath + : GetStagedOutputPath(publisherName, definition, targetPath); + var resolved = new ResolvedPipelineOutput( + publisherName, + definition.Name, + definition.Kind, + outputPath, + targetPath, + isPrimary: false); + + if (_outputs.TryGetValue(key, out var existing)) + { + if (existing.Kind == resolved.Kind && + PathEquals(existing.OutputPath, resolved.OutputPath) && + PathEquals(existing.LogicalTargetPath, resolved.LogicalTargetPath)) + { + return; + } + + throw new InvalidOperationException( + $"Pipeline output '{publisherName}/{definition.Name}' was declared more than once with conflicting metadata."); + } + + _outputs.Add(key, resolved); + } + + private string ResolveTargetPath(string path) + { + return Path.GetFullPath(path, AppHostDirectory); + } + + private string GetStagedOutputPath( + string publisherName, + PipelineOutputDefinition definition, + string targetPath) + { + var identity = $"{publisherName}\0{definition.Name}\0{targetPath}"; + var hash = Convert.ToHexString(XxHash3.Hash(Encoding.UTF8.GetBytes(identity))).ToLowerInvariant(); + var extension = definition.Kind == PipelineOutputKind.File + ? TruncateToUtf8ByteCount( + SanitizePathSegment(Path.GetExtension(targetPath)), + MaximumStagedOutputExtensionByteCount) + : string.Empty; + var fixedByteCount = hash.Length + Encoding.UTF8.GetByteCount(extension) + 2; + var readableByteCount = MaximumStagedOutputNameByteCount - fixedByteCount; + var publisherByteCount = readableByteCount / 2; + var outputByteCount = readableByteCount - publisherByteCount; + var publisherSegment = TruncateToUtf8ByteCount(SanitizePathSegment(publisherName), publisherByteCount); + var outputSegment = TruncateToUtf8ByteCount(SanitizePathSegment(definition.Name), outputByteCount); + var leafName = $"{publisherSegment}-{outputSegment}-{hash}{extension}"; + + return Path.Combine(_stagingPath!, "outputs", leafName); + } + + private bool SupportsOutputPathRelocationCore(PipelineStep step) + { + return step.OutputPathRelocationSupportEvaluator?.Invoke(_primaryOutput) + ?? step.SupportsOutputPathRelocation; + } + + private void ValidateExclusiveOwnership() + { + var outputs = _outputs.Values.Append(_primaryOutput).ToArray(); + for (var i = 0; i < outputs.Length; i++) + { + for (var j = i + 1; j < outputs.Length; j++) + { + var first = outputs[i]; + var second = outputs[j]; + + if (_stagingPath is null && TryGetPrimaryAndNamedOutput(first, second, out var primary, out var named) && + primary.Kind == PipelineOutputKind.Directory && + !PathEquals(primary.LogicalTargetPath, named.LogicalTargetPath) && + IsNestedPath(named.LogicalTargetPath, primary.LogicalTargetPath)) + { + continue; + } + + if (PathsOverlap(first.LogicalTargetPath, second.LogicalTargetPath)) + { + throw new InvalidOperationException( + $"Pipeline outputs '{first.PublisherName}/{first.Name}' and " + + $"'{second.PublisherName}/{second.Name}' have overlapping target paths."); + } + + if (_stagingPath is not null && PathsOverlap(first.OutputPath, second.OutputPath)) + { + throw new InvalidOperationException( + $"Pipeline outputs '{first.PublisherName}/{first.Name}' and " + + $"'{second.PublisherName}/{second.Name}' have overlapping writable paths."); + } + } + } + } + + private static bool TryGetPrimaryAndNamedOutput( + ResolvedPipelineOutput first, + ResolvedPipelineOutput second, + out ResolvedPipelineOutput primary, + out ResolvedPipelineOutput named) + { + if (first.IsPrimary) + { + primary = first; + named = second; + return true; + } + + if (second.IsPrimary) + { + primary = second; + named = first; + return true; + } + + primary = first; + named = second; + return false; + } + + private static bool PathsOverlap(string first, string second) + { + return PathEquals(first, second) || + IsNestedPath(first, second) || + IsNestedPath(second, first); + } + + private static bool IsNestedPath(string path, string possibleParent) + { + var parentWithSeparator = Path.EndsInDirectorySeparator(possibleParent) + ? possibleParent + : possibleParent + Path.DirectorySeparatorChar; + return path.StartsWith(parentWithSeparator, PathComparison); + } + + private static bool PathEquals(string first, string second) + { + return string.Equals(first, second, PathComparison); + } + + private static StringComparison PathComparison => + OperatingSystem.IsWindows() || OperatingSystem.IsMacOS() + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal; + + private static string? NormalizeOptionalPath(string? path, string basePath) + { + return string.IsNullOrWhiteSpace(path) ? null : Path.GetFullPath(path, basePath); + } + + private static void ValidateIdentifier(string value, string parameterName) + { + if (string.IsNullOrWhiteSpace(value) || value.Contains(':')) + { + throw new ArgumentException( + "Pipeline output identifiers cannot be empty or contain ':'.", + parameterName); + } + } + + private static void ValidateTargetKind( + string publisherName, + string outputName, + string targetPath, + PipelineOutputKind kind) + { + if (kind == PipelineOutputKind.File && Directory.Exists(targetPath)) + { + throw new InvalidOperationException( + $"Pipeline output '{publisherName}/{outputName}' declares a file, but its target path is an existing directory."); + } + + if (kind == PipelineOutputKind.Directory && File.Exists(targetPath)) + { + throw new InvalidOperationException( + $"Pipeline output '{publisherName}/{outputName}' declares a directory, but its target path is an existing file."); + } + } + + private static string SanitizePathSegment(string value) + { + var invalidCharacters = Path.GetInvalidFileNameChars(); + var characters = value + .Select(character => invalidCharacters.Contains(character) ? '-' : character) + .ToArray(); + return new string(characters); + } + + private static string TruncateToUtf8ByteCount(string value, int maximumByteCount) + { + if (Encoding.UTF8.GetByteCount(value) <= maximumByteCount) + { + return value; + } + + var builder = new StringBuilder(value.Length); + var byteCount = 0; + foreach (var rune in value.EnumerateRunes()) + { + if (byteCount + rune.Utf8SequenceLength > maximumByteCount) + { + break; + } + + builder.Append(rune.ToString()); + byteCount += rune.Utf8SequenceLength; + } + + return builder.ToString(); + } + + private void EnsurePrepared() + { + if (_preparationException is not null) + { + ExceptionDispatchInfo.Capture(_preparationException).Throw(); + } + + if (!_prepared) + { + throw new InvalidOperationException("The pipeline output plan has not been prepared."); + } + } + + private readonly record struct OutputKey(string PublisherName, string Name); +} + +internal sealed class PipelineStepOutputResolver : IPipelineOutputResolver +{ + private readonly Lazy _registry; + private readonly PipelineStep _step; + + public PipelineStepOutputResolver(PipelineOutputRegistry registry, PipelineStep step) + { + ArgumentNullException.ThrowIfNull(registry); + ArgumentNullException.ThrowIfNull(step); + + _registry = new(() => registry); + _step = step; + } + + public PipelineStepOutputResolver(IServiceProvider services, PipelineStep step) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(step); + + _registry = new(services.GetRequiredService); + _step = step; + } + + public string AppHostDirectory => _registry.Value.AppHostDirectory; + + public ResolvedPipelineOutput PrimaryOutput => _registry.Value.GetPrimaryOutput(); + + public ResolvedPipelineOutput Resolve(PipelineOutputDefinition definition) + { + return _registry.Value.Resolve(_step, definition); + } +} + +internal sealed class UnavailablePipelineOutputResolver : IPipelineOutputResolver +{ + private const string ErrorMessage = "Pipeline outputs are only available while a pipeline step is executing."; + + public static UnavailablePipelineOutputResolver Instance { get; } = new(); + + private UnavailablePipelineOutputResolver() + { + } + + public string AppHostDirectory => throw new InvalidOperationException(ErrorMessage); + + public ResolvedPipelineOutput PrimaryOutput => throw new InvalidOperationException(ErrorMessage); + + public ResolvedPipelineOutput Resolve(PipelineOutputDefinition definition) + { + ArgumentNullException.ThrowIfNull(definition); + throw new InvalidOperationException(ErrorMessage); + } +} + +internal enum PipelineOutputExecutionState +{ + Preparing, + Prepared, + Succeeded, + Failed +} diff --git a/src/Aspire.Hosting/Pipelines/PipelineStep.cs b/src/Aspire.Hosting/Pipelines/PipelineStep.cs index 23409c238ce..53156613370 100644 --- a/src/Aspire.Hosting/Pipelines/PipelineStep.cs +++ b/src/Aspire.Hosting/Pipelines/PipelineStep.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. #pragma warning disable ASPIREPIPELINES001 +#pragma warning disable ASPIREPIPELINES004 using System.Diagnostics; using System.Diagnostics.CodeAnalysis; @@ -52,6 +53,31 @@ public class PipelineStep /// public List Tags { get; init; } = []; + /// + /// Gets or initializes the named outputs produced by this step. + /// + /// + /// Pipeline actions must resolve these definitions through + /// and write only to the returned output path. + /// + [AspireExportIgnore(Reason = "Publisher output declarations are currently available to .NET pipeline-step authors only.")] + public IReadOnlyList Outputs { get; init; } = []; + + /// + /// Gets or initializes a value indicating whether every persistent output produced by this step + /// supports relocation. + /// + /// + /// Set this to only when the step writes no persistent output, or when all + /// persistent writes use the primary pipeline output or an output declared in . + /// Verification rejects selected steps that do not opt in before any pipeline action executes. + /// + [AspireExportIgnore(Reason = "Publisher output relocation is currently available to .NET pipeline-step authors only.")] + public bool SupportsOutputPathRelocation { get; init; } + + // Built-in steps can use this when relocation support depends on the resolved primary output shape. + internal Func? OutputPathRelocationSupportEvaluator { get; init; } + /// /// Gets or initializes the resource that this step is associated with, if any. /// @@ -112,7 +138,8 @@ public void RequiredBy(PipelineStep step) /// /// Creates a shallow clone of this step with fresh copies of its /// , , and - /// lists. Used by + /// lists and its collection. + /// Used by /// when isolating step-graph mutations during a phase such as BeforeStart. /// internal PipelineStep Clone() @@ -125,6 +152,9 @@ internal PipelineStep Clone() DependsOnSteps = [.. DependsOnSteps], RequiredBySteps = [.. RequiredBySteps], Tags = [.. Tags], + Outputs = [.. Outputs], + SupportsOutputPathRelocation = SupportsOutputPathRelocation, + OutputPathRelocationSupportEvaluator = OutputPathRelocationSupportEvaluator, Resource = Resource, }; } diff --git a/src/Aspire.Hosting/Pipelines/PipelineStepContext.cs b/src/Aspire.Hosting/Pipelines/PipelineStepContext.cs index c46bf9ed8fa..ba0424a08b4 100644 --- a/src/Aspire.Hosting/Pipelines/PipelineStepContext.cs +++ b/src/Aspire.Hosting/Pipelines/PipelineStepContext.cs @@ -1,6 +1,8 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +#pragma warning disable ASPIREPIPELINES004 + using System.Diagnostics.CodeAnalysis; using Aspire.Hosting.ApplicationModel; using Microsoft.Extensions.Logging; @@ -31,6 +33,12 @@ public sealed class PipelineStepContext /// public required IReportingStep ReportingStep { get; init; } + /// + /// Gets the resolver for outputs declared by the current pipeline step. + /// + [AspireExportIgnore(Reason = "Publisher output resolution is currently available to .NET pipeline-step authors only.")] + public IPipelineOutputResolver Outputs { get; internal init; } = UnavailablePipelineOutputResolver.Instance; + /// /// Gets the distributed application model to be deployed. /// diff --git a/src/Aspire.Hosting/Pipelines/ResolvedPipelineOutput.cs b/src/Aspire.Hosting/Pipelines/ResolvedPipelineOutput.cs new file mode 100644 index 00000000000..0353745249f --- /dev/null +++ b/src/Aspire.Hosting/Pipelines/ResolvedPipelineOutput.cs @@ -0,0 +1,58 @@ +// 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; + +namespace Aspire.Hosting.Pipelines; + +/// +/// Represents a pipeline output after its configured and effective paths have been resolved. +/// +[Experimental("ASPIREPIPELINES004", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] +public sealed class ResolvedPipelineOutput +{ + internal ResolvedPipelineOutput( + string publisherName, + string name, + PipelineOutputKind kind, + string outputPath, + string logicalTargetPath, + bool isPrimary) + { + PublisherName = publisherName; + Name = name; + Kind = kind; + OutputPath = outputPath; + LogicalTargetPath = logicalTargetPath; + IsPrimary = isPrimary; + } + + internal string PublisherName { get; } + + internal string Name { get; } + + internal bool IsPrimary { get; } + + /// + /// Gets the kind of output. + /// + public PipelineOutputKind Kind { get; } + + /// + /// Gets the path where the pipeline step must write the output. + /// + /// + /// This path can differ from when a caller redirects output, + /// such as when verifying generated artifacts without modifying their checked-in targets. + /// + public string OutputPath { get; } + + /// + /// Gets the configured logical target path for the output. + /// + /// + /// Use this path when generated content needs to refer to its eventual destination. Always use + /// for file-system writes. + /// + public string LogicalTargetPath { get; } +} diff --git a/src/Aspire.Hosting/Pipelines/WellKnownPipelineSteps.cs b/src/Aspire.Hosting/Pipelines/WellKnownPipelineSteps.cs index 4b3f0b97459..8eb062665a3 100644 --- a/src/Aspire.Hosting/Pipelines/WellKnownPipelineSteps.cs +++ b/src/Aspire.Hosting/Pipelines/WellKnownPipelineSteps.cs @@ -105,4 +105,6 @@ public static class WellKnownPipelineSteps /// [AspireValue("WellKnownPipelineSteps")] public const string DestroyPrereq = "destroy-prereq"; + + internal const string PublishManifest = "publish-manifest"; } diff --git a/src/Aspire.Hosting/Publishing/BeforePublishEvent.cs b/src/Aspire.Hosting/Publishing/BeforePublishEvent.cs index b8d7b0100a6..35bb59cb2a0 100644 --- a/src/Aspire.Hosting/Publishing/BeforePublishEvent.cs +++ b/src/Aspire.Hosting/Publishing/BeforePublishEvent.cs @@ -9,6 +9,11 @@ namespace Aspire.Hosting.Publishing; /// /// This event is published before the distributed application is published. /// +/// +/// The selected pipeline step graph and its output declarations are resolved before this event is +/// published. Handlers can mutate model data consumed by those steps, but changes that add or +/// reconfigure pipeline steps do not affect the current publication. +/// /// The for the app host. /// The . [AspireExport(ExposeProperties = true)] diff --git a/src/Aspire.Hosting/Publishing/ManifestPublishingContext.cs b/src/Aspire.Hosting/Publishing/ManifestPublishingContext.cs index 334d47cefdf..1af1bf758d9 100644 --- a/src/Aspire.Hosting/Publishing/ManifestPublishingContext.cs +++ b/src/Aspire.Hosting/Publishing/ManifestPublishingContext.cs @@ -14,31 +14,59 @@ namespace Aspire.Hosting.Publishing; /// /// Contextual information used for manifest publishing during this execution of the AppHost. /// -/// Global contextual information for this invocation of the AppHost. -/// Manifest path passed in for this invocation of the AppHost. -/// JSON writer used to writing the manifest. -/// Cancellation token for this operation. -public sealed class ManifestPublishingContext(DistributedApplicationExecutionContext executionContext, string manifestPath, Utf8JsonWriter writer, CancellationToken cancellationToken = default) +public sealed class ManifestPublishingContext { + /// + /// Initializes a new instance of the class. + /// + /// Global contextual information for this invocation of the AppHost. + /// Manifest path passed in for this invocation of the AppHost. + /// JSON writer used to writing the manifest. + /// Cancellation token for this operation. + public ManifestPublishingContext( + DistributedApplicationExecutionContext executionContext, + string manifestPath, + Utf8JsonWriter writer, + CancellationToken cancellationToken = default) + : this(executionContext, manifestPath, manifestPath, writer, cancellationToken) + { + } + + internal ManifestPublishingContext( + DistributedApplicationExecutionContext executionContext, + string manifestPath, + string manifestPathForRelativePaths, + Utf8JsonWriter writer, + CancellationToken cancellationToken) + { + ExecutionContext = executionContext; + ManifestPath = manifestPath; + Writer = writer; + CancellationToken = cancellationToken; + _manifestPathForRelativePaths = manifestPathForRelativePaths; + } + /// /// Gets execution context for this invocation of the AppHost. /// - public DistributedApplicationExecutionContext ExecutionContext { get; } = executionContext; + public DistributedApplicationExecutionContext ExecutionContext { get; } /// /// Gets manifest path specified for this invocation of the AppHost. /// - public string ManifestPath { get; } = manifestPath; + public string ManifestPath { get; } /// /// Gets JSON writer for writing manifest entries. /// - public Utf8JsonWriter Writer { get; } = writer; + public Utf8JsonWriter Writer { get; } /// /// Gets cancellation token for this operation. /// - public CancellationToken CancellationToken { get; } = cancellationToken; + public CancellationToken CancellationToken { get; } + + private readonly string _manifestPathForRelativePaths; private readonly Dictionary _referencedResources = []; @@ -66,15 +94,43 @@ public sealed class ManifestPublishingContext(DistributedApplicationExecutionCon return null; } - var fullyQualifiedManifestPath = Path.GetFullPath(ManifestPath); - var manifestDirectory = Path.GetDirectoryName(fullyQualifiedManifestPath) ?? throw new DistributedApplicationException("Could not get directory name of output path"); - var normalizedPath = path.Replace('\\', Path.DirectorySeparatorChar).Replace('/', Path.DirectorySeparatorChar); - var relativePath = Path.GetRelativePath(manifestDirectory, normalizedPath); + var fullyQualifiedPath = Path.GetFullPath(normalizedPath); + var manifestDirectory = GetManifestDirectory(ManifestPath); + var relativePathFromPhysicalManifest = Path.GetRelativePath(manifestDirectory, fullyQualifiedPath); + string relativePath; + + if (IsWithinDirectory(relativePathFromPhysicalManifest)) + { + // Generated companions such as Dockerfiles and Bicep modules are written beside the + // physical manifest. Their relative paths are identical beside the logical target. + relativePath = relativePathFromPhysicalManifest; + } + else + { + // Source paths must remain relative to the checked-in target rather than the isolated + // staging directory used by relocated publishing. + relativePath = Path.GetRelativePath(GetManifestDirectory(_manifestPathForRelativePaths), fullyQualifiedPath); + } return relativePath.Replace('\\', '/'); } + private static string GetManifestDirectory(string manifestPath) + { + var fullyQualifiedManifestPath = Path.GetFullPath(manifestPath); + return Path.GetDirectoryName(fullyQualifiedManifestPath) + ?? throw new DistributedApplicationException("Could not get directory name of output path"); + } + + private static bool IsWithinDirectory(string relativePath) + { + return !Path.IsPathFullyQualified(relativePath) && + relativePath != ".." && + !relativePath.StartsWith($"..{Path.DirectorySeparatorChar}", StringComparison.Ordinal) && + !relativePath.StartsWith($"..{Path.AltDirectorySeparatorChar}", StringComparison.Ordinal); + } + internal async Task WriteModel(DistributedApplicationModel model, CancellationToken cancellationToken) { _formattedParameters.Clear(); diff --git a/src/Aspire.Hosting/Publishing/ManifestPublishingExtensions.cs b/src/Aspire.Hosting/Publishing/ManifestPublishingExtensions.cs index e4b20047a72..6ba3115be12 100644 --- a/src/Aspire.Hosting/Publishing/ManifestPublishingExtensions.cs +++ b/src/Aspire.Hosting/Publishing/ManifestPublishingExtensions.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. #pragma warning disable ASPIREPIPELINES001 +#pragma warning disable ASPIREPIPELINES004 using System.Text.Json; using Aspire.Hosting.Pipelines; @@ -26,8 +27,12 @@ public static IDistributedApplicationPipeline AddManifestPublishing(this IDistri { var step = new PipelineStep { - Name = "publish-manifest", + Name = WellKnownPipelineSteps.PublishManifest, Description = "Publishes the Aspire application model as a JSON manifest file.", + // A legacy file target can emit Dockerfiles and Bicep modules beside the manifest. + // Those siblings are not represented by the single-file primary output, so only the + // directory form can safely participate in relocated publishing. + OutputPathRelocationSupportEvaluator = primaryOutput => primaryOutput.Kind == PipelineOutputKind.Directory, Action = async context => { var loggerFactory = context.Services.GetRequiredService(); @@ -38,21 +43,12 @@ public static IDistributedApplicationPipeline AddManifestPublishing(this IDistri if (pipelineOptions.Value.OutputPath == null) { throw new DistributedApplicationException( - "The '--output-path [path]' option was not specified even though manifest publishing was requested." - ); + "The '--output-path [path]' option was not specified even though manifest publishing was requested."); } - var outputPath = pipelineOptions.Value.OutputPath; - - if (!outputPath.EndsWith(".json", StringComparison.Ordinal)) - { - // If the manifest path ends with .json we assume that the output path was specified - // as a filename. If not, we assume that the output path was specified as a directory - // and append aspire-manifest.json to the path. This is so that we retain backwards - // compatibility with AZD, but also support manifest publishing via the Aspire CLI - // where the output path is a directory (since not all publishers use a manifest). - outputPath = Path.Combine(outputPath, "aspire-manifest.json"); - } + var primaryOutput = context.Outputs.PrimaryOutput; + var outputPath = GetManifestPath(primaryOutput.OutputPath, primaryOutput.Kind); + var logicalManifestPath = GetManifestPath(primaryOutput.LogicalTargetPath, primaryOutput.Kind); var parentDirectory = Directory.GetParent(outputPath); if (!Directory.Exists(parentDirectory!.FullName)) @@ -64,8 +60,12 @@ public static IDistributedApplicationPipeline AddManifestPublishing(this IDistri using var stream = new FileStream(outputPath, FileMode.Create); using var jsonWriter = new Utf8JsonWriter(stream, new JsonWriterOptions { Indented = true }); - var manifestPath = outputPath; - var publishingContext = new ManifestPublishingContext(executionContext, manifestPath, jsonWriter, context.CancellationToken); + var publishingContext = new ManifestPublishingContext( + executionContext, + outputPath, + logicalManifestPath, + jsonWriter, + context.CancellationToken); await publishingContext.WriteModel(context.Model, context.CancellationToken).ConfigureAwait(false); @@ -77,4 +77,16 @@ public static IDistributedApplicationPipeline AddManifestPublishing(this IDistri return pipeline; } + + private static string GetManifestPath(string outputPath, PipelineOutputKind kind) + { + if (kind == PipelineOutputKind.File) + { + return outputPath; + } + + // The registry preserves the legacy direct-manifest file-path contract by assigning + // File kind. Shared primary outputs are directories and use the standard manifest name. + return Path.Combine(outputPath, "aspire-manifest.json"); + } } diff --git a/src/Aspire.Hosting/Publishing/PipelineExecutor.cs b/src/Aspire.Hosting/Publishing/PipelineExecutor.cs index 17e3019fef0..c2f40f38d34 100644 --- a/src/Aspire.Hosting/Publishing/PipelineExecutor.cs +++ b/src/Aspire.Hosting/Publishing/PipelineExecutor.cs @@ -23,6 +23,7 @@ internal sealed class PipelineExecutor( IPipelineActivityReporter activityReporter, IDistributedApplicationEventing eventing, BackchannelService backchannelService, + DistributedApplicationPipeline pipeline, IPipelineActivityReporter pipelineActivityReporter) : BackgroundService { protected override async Task ExecuteAsync(CancellationToken stoppingToken) @@ -45,22 +46,32 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) // Set the current step in the logger provider so that logs are associated with the correct pipeline step PipelineLoggerProvider.CurrentStep = step; + PipelineOutputRegistry? outputRegistry = null; try { + outputRegistry = serviceProvider.GetRequiredService(); + var pipelineContext = CreatePipelineContext(model, stoppingToken); + var executionPlan = await pipeline.PrepareAsync(pipelineContext).ConfigureAwait(false); + + // Relocated executions are paused here so the CLI can inspect and validate the + // frozen plan before BeforePublishEvent or any pipeline action can mutate outputs. + await outputRegistry.WaitForExecutionAuthorizationAsync(stoppingToken).ConfigureAwait(false); + await eventing.PublishAsync( new BeforePublishEvent(serviceProvider, model), stoppingToken ).ConfigureAwait(false); // Execute the pipeline and get the summary from the context - var pipelineSummary = await ExecutePipelineAsync(model, stoppingToken).ConfigureAwait(false); + await DistributedApplicationPipeline.ExecuteAsync(executionPlan, pipelineContext).ConfigureAwait(false); await eventing.PublishAsync( new AfterPublishEvent(serviceProvider, model), stoppingToken ).ConfigureAwait(false); // Get the pipeline summary items (preserves insertion order) - var summaryItems = pipelineSummary.Items.Count > 0 ? pipelineSummary.Items : null; + var summaryItems = pipelineContext.Summary.Items.Count > 0 ? pipelineContext.Summary.Items : null; + outputRegistry.MarkExecutionSucceeded(); await step.SucceedAsync(cancellationToken: stoppingToken).ConfigureAwait(false); await activityReporter.CompletePublishAsync(new PublishCompletionOptions { PipelineSummary = summaryItems }, stoppingToken).ConfigureAwait(false); @@ -76,6 +87,7 @@ await eventing.PublishAsync( } catch (Exception ex) { + outputRegistry?.MarkExecutionFailed(ex); logger.LogError(ex, ex.Message); await step.FailAsync(cancellationToken: stoppingToken).ConfigureAwait(false); @@ -100,11 +112,15 @@ await eventing.PublishAsync( /// public async Task ExecutePipelineAsync(DistributedApplicationModel model, CancellationToken cancellationToken) { - var pipelineContext = new PipelineContext(model, executionContext, serviceProvider, logger, cancellationToken); + var pipelineContext = CreatePipelineContext(model, cancellationToken); - var pipeline = serviceProvider.GetRequiredService(); await pipeline.ExecuteAsync(pipelineContext).ConfigureAwait(false); return pipelineContext.Summary; } + + private PipelineContext CreatePipelineContext(DistributedApplicationModel model, CancellationToken cancellationToken) + { + return new PipelineContext(model, executionContext, serviceProvider, logger, cancellationToken); + } } diff --git a/src/Aspire.Hosting/README.md b/src/Aspire.Hosting/README.md index 442a3000465..f9e329d7172 100644 --- a/src/Aspire.Hosting/README.md +++ b/src/Aspire.Hosting/README.md @@ -26,6 +26,46 @@ Aspire's approach ensures flexibility, strong tooling support, and clear separat For the full details and specification, see the [App Model document](https://github.com/microsoft/aspire/blob/main/docs/specs/appmodel.md). +## Publisher output paths + +Custom pipeline publishers must declare each persistent named output on the owning `PipelineStep` before the pipeline runs. Default paths are relative to the AppHost project directory, so publishers do not need to search for a repository root: + +```csharp +var inventoryDefinition = new PipelineOutputDefinition( + "inventory", + ".configgen", + PipelineOutputKind.Directory); + +var step = new PipelineStep +{ + Name = "generate-inventory", + RequiredBySteps = [WellKnownPipelineSteps.Publish], + Outputs = [inventoryDefinition], + SupportsOutputPathRelocation = true, + Action = context => + { + var inventory = context.Outputs.Resolve(inventoryDefinition); + Directory.CreateDirectory(inventory.OutputPath); + + // Write only through OutputPath. Use LogicalTargetPath when generated + // content needs to refer to its checked-in destination. + File.WriteAllText( + Path.Combine(inventory.OutputPath, "inventory.txt"), + inventory.LogicalTargetPath); + + return Task.CompletedTask; + } +}; + +builder.Pipeline.AddStep(step); +``` + +`RequiredBySteps = [WellKnownPipelineSteps.Publish]` includes the custom publisher when `aspire publish` runs. + +`context.Outputs.AppHostDirectory` is the authoritative AppHost project directory. `OutputPath` is the only path a publisher should write to, while `LogicalTargetPath` identifies the normal checked-in destination and can differ when output relocation is active. Configuration can override a named target with `Pipeline:Outputs:::Path`. The existing primary `--output-path` destination is available through `context.Outputs.PrimaryOutput` and should not be redeclared as a named output. + +Set `SupportsOutputPathRelocation` to `true` only when the step has no other persistent writes. Named destinations must not overlap each other, and verification-compatible destinations should remain inside the AppHost Git repository. + ## Feedback & contributing https://github.com/microsoft/aspire diff --git a/src/Shared/PublishingContextUtils.cs b/src/Shared/PublishingContextUtils.cs index 185aa207c17..c7f5d5efbb1 100644 --- a/src/Shared/PublishingContextUtils.cs +++ b/src/Shared/PublishingContextUtils.cs @@ -6,7 +6,6 @@ using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.Pipelines; -using Microsoft.Extensions.DependencyInjection; namespace Aspire.Hosting.Utils; @@ -14,14 +13,14 @@ internal static class PublishingContextUtils { public static string GetEnvironmentOutputPath(PipelineStepContext context, IComputeEnvironmentResource environment) { - var outputService = context.Services.GetRequiredService(); + var primaryOutputPath = context.Outputs.PrimaryOutput.OutputPath; if (context.Model.Resources.OfType().Count() > 1) { // If there are multiple compute environments, use resource-specific output path - return outputService.GetOutputDirectory(environment); + return Path.Combine(primaryOutputPath, environment.Name); } // If there is only one compute environment, use the root output path - return outputService.GetOutputDirectory(); + return primaryOutputPath; } } diff --git a/tests/Aspire.Cli.Tests/Backchannel/BackchannelJsonSerializerContextTests.cs b/tests/Aspire.Cli.Tests/Backchannel/BackchannelJsonSerializerContextTests.cs index 4d624ba512b..f03eff76730 100644 --- a/tests/Aspire.Cli.Tests/Backchannel/BackchannelJsonSerializerContextTests.cs +++ b/tests/Aspire.Cli.Tests/Backchannel/BackchannelJsonSerializerContextTests.cs @@ -177,4 +177,46 @@ public void ListTerminalsResponse_RoundTripsThroughSerializer() Assert.Equal("peer-1", replica.Peers[0].PeerId); Assert.Equal("viewer-1", replica.Peers[0].DisplayName); } + + [Fact] + public void PipelineOutputPlan_RoundTripsThroughSerializer() + { + var options = BackchannelJsonSerializerContext.CreateJsonSerializerOptions(); + var response = new GetPipelineOutputsResponse + { + AppHostDirectory = @"C:\repo\src\AppHost", + State = "Prepared", + Steps = + [ + new PipelineOutputStepInfo + { + Name = "generate-config", + SupportsOutputPathRelocation = true + } + ], + Outputs = + [ + new PipelineOutputInfo + { + IsPrimary = false, + PublisherName = "generate-config", + Name = "inventory", + Kind = "Directory", + OutputPath = @"C:\temp\outputs\inventory", + LogicalTargetPath = @"C:\repo\src\AppHost\.configgen" + } + ] + }; + + var json = JsonSerializer.Serialize(response, options); + var roundTripped = JsonSerializer.Deserialize(json, options); + + Assert.NotNull(roundTripped); + Assert.Equal(response.AppHostDirectory, roundTripped.AppHostDirectory); + Assert.Equal("generate-config", Assert.Single(roundTripped.Steps).Name); + Assert.False(Assert.Single(roundTripped.Outputs).IsPrimary); + Assert.Equal( + response.Outputs[0].LogicalTargetPath, + Assert.Single(roundTripped.Outputs).LogicalTargetPath); + } } diff --git a/tests/Aspire.Cli.Tests/Commands/PublishCommandPromptingIntegrationTests.cs b/tests/Aspire.Cli.Tests/Commands/PublishCommandPromptingIntegrationTests.cs index a39ab4f9425..a6c3e4500d7 100644 --- a/tests/Aspire.Cli.Tests/Commands/PublishCommandPromptingIntegrationTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/PublishCommandPromptingIntegrationTests.cs @@ -1116,6 +1116,17 @@ public async IAsyncEnumerable GetResourceStatesAsync([Enumerat public Task GetPipelineStepsAsync(string? step, CancellationToken cancellationToken) => Task.FromResult(new GetPipelineStepsResponse { Steps = [] }); + public Task GetPipelineOutputsAsync(CancellationToken cancellationToken) => + Task.FromResult(new GetPipelineOutputsResponse + { + AppHostDirectory = Environment.CurrentDirectory, + State = "Prepared", + Steps = [], + Outputs = [] + }); + + public Task AuthorizePipelineExecutionAsync(CancellationToken cancellationToken) => Task.CompletedTask; + public Task UploadFileAsync(string filePath, string fileName, CancellationToken cancellationToken) { UploadedFiles.Add((filePath, fileName)); diff --git a/tests/Aspire.Cli.Tests/TestServices/TestAppHostCliBackchannel.cs b/tests/Aspire.Cli.Tests/TestServices/TestAppHostCliBackchannel.cs index 157e12f9151..dce1b63f4de 100644 --- a/tests/Aspire.Cli.Tests/TestServices/TestAppHostCliBackchannel.cs +++ b/tests/Aspire.Cli.Tests/TestServices/TestAppHostCliBackchannel.cs @@ -34,6 +34,9 @@ internal sealed class TestAppHostBackchannel : IAppHostCliBackchannel public TaskCompletionSource? GetPipelineStepsAsyncCalled { get; set; } public Func>? GetPipelineStepsAsyncCallback { get; set; } + public TaskCompletionSource? GetPipelineOutputsAsyncCalled { get; set; } + public Func>? GetPipelineOutputsAsyncCallback { get; set; } + public Task RequestStopAsync(CancellationToken cancellationToken) { RequestStopAsyncCalled?.SetResult(); @@ -279,6 +282,25 @@ public async Task GetPipelineStepsAsync(string? step, }; } + public async Task GetPipelineOutputsAsync(CancellationToken cancellationToken) + { + GetPipelineOutputsAsyncCalled?.SetResult(); + if (GetPipelineOutputsAsyncCallback is not null) + { + return await GetPipelineOutputsAsyncCallback(cancellationToken).ConfigureAwait(false); + } + + return new GetPipelineOutputsResponse + { + AppHostDirectory = Environment.CurrentDirectory, + State = "Prepared", + Steps = [], + Outputs = [] + }; + } + + public Task AuthorizePipelineExecutionAsync(CancellationToken cancellationToken) => Task.CompletedTask; + public Func>? UploadFileAsyncCallback { get; set; } public async Task UploadFileAsync(string filePath, string fileName, CancellationToken cancellationToken) diff --git a/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.ts b/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.ts index 727460ff55b..b57d638ea72 100644 --- a/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.ts +++ b/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.ts @@ -461,7 +461,13 @@ type ProjectResourceOptionsHandle = Handle<'Aspire.Hosting/Aspire.Hosting.Projec /** This event is published after the distributed application is published. */ type AfterPublishEventHandle = Handle<'Aspire.Hosting/Aspire.Hosting.Publishing.AfterPublishEvent'>; -/** This event is published before the distributed application is published. */ +/** + * This event is published before the distributed application is published. + * + * The selected pipeline step graph and its output declarations are resolved before this event is + * published. Handlers can mutate model data consumed by those steps, but changes that add or + * reconfigure pipeline steps do not affect the current publication. + */ type BeforePublishEventHandle = Handle<'Aspire.Hosting/Aspire.Hosting.Publishing.BeforePublishEvent'>; /** Handle to IConfiguration */ @@ -2090,7 +2096,13 @@ class AfterResourcesCreatedEventPromiseImpl implements AfterResourcesCreatedEven // BeforePublishEvent // ============================================================================ -/** This event is published before the distributed application is published. */ +/** + * This event is published before the distributed application is published. + * + * The selected pipeline step graph and its output declarations are resolved before this event is + * published. Handlers can mutate model data consumed by those steps, but changes that add or + * reconfigure pipeline steps do not affect the current publication. + */ export interface BeforePublishEvent { toJSON(): MarshalledHandle; /** The `IServiceProvider` for the app host. */ @@ -2110,7 +2122,13 @@ export interface BeforePublishEventPromise extends PromiseLike( + () => new PipelineOutputDefinition( + "inventory", + Path.GetFullPath(".configgen"), + PipelineOutputKind.Directory)); + + Assert.Equal("defaultPath", exception.ParamName); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void Registry_RejectsIncompleteRelocationConfiguration(bool configureStagingPath) + { + var bootstrapConfiguration = new ConfigurationBuilder().Build(); + using var fileSystem = new FileSystemService(bootstrapConfiguration); + var root = fileSystem.TempDirectory.CreateTempSubdirectory("pipeline-relocation-configuration-tests").Path; + var appHostDirectory = Path.Combine(root, "AppHost"); + Directory.CreateDirectory(appHostDirectory); + var configurationValues = new Dictionary + { + ["AppHost:Directory"] = appHostDirectory, + [configureStagingPath ? PipelineOutputRegistry.StagingPathConfigurationKey : PipelineOutputRegistry.TargetOutputPathConfigurationKey] = + Path.Combine(root, configureStagingPath ? "staging" : "target") + }; + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(configurationValues) + .Build(); + var pipelineOptions = Options.Create(new PipelineOptions { OutputPath = Path.Combine(root, "primary") }); + var outputService = new PipelineOutputService(pipelineOptions, configuration, fileSystem); + + var exception = Assert.Throws( + () => new PipelineOutputRegistry(configuration, outputService, pipelineOptions)); + + Assert.Contains("must be specified together", exception.Message); + } + + [Fact] + public async Task MarkExecutionFailed_UnblocksPreparationWait() + { + using var fixture = CreateRegistry(); + var failure = new InvalidOperationException("Pipeline preparation failed."); + + fixture.Registry.MarkExecutionFailed(failure); + + var exception = await Assert.ThrowsAsync( + () => fixture.Registry.WaitForPreparationAsync(CancellationToken.None)); + Assert.Same(failure, exception); + Assert.Equal(PipelineOutputExecutionState.Failed, fixture.Registry.GetExecutionState()); + } + + [Fact] + public void Prepare_ResolvesNamedOutputsFromAppHostDirectory() + { + using var fixture = CreateRegistry(); + var configgen = new PipelineOutputDefinition("inventory", ".configgen", PipelineOutputKind.Directory); + var pipelines = new PipelineOutputDefinition("pipelines", ".pipelines", PipelineOutputKind.Directory); + var step = CreateStep("config-generator", configgen, pipelines); + + fixture.Registry.Prepare([step]); + + var resolver = new PipelineStepOutputResolver(fixture.Registry, step); + Assert.Equal(fixture.AppHostDirectory, resolver.AppHostDirectory); + Assert.Equal(fixture.PrimaryOutputPath, resolver.PrimaryOutput.OutputPath); + Assert.Equal( + Path.Combine(fixture.AppHostDirectory, ".configgen"), + resolver.Resolve(configgen).OutputPath); + Assert.Equal( + Path.Combine(fixture.AppHostDirectory, ".pipelines"), + resolver.Resolve(pipelines).LogicalTargetPath); + } + + [Fact] + public async Task Prepare_WithRelocation_FreezesPlanUntilAuthorized() + { + using var fixture = CreateRegistry(relocate: true); + var definition = new PipelineOutputDefinition("inventory", ".configgen", PipelineOutputKind.Directory); + var step = CreateStep("config-generator", definition); + + fixture.Registry.Prepare([step]); + + var output = new PipelineStepOutputResolver(fixture.Registry, step).Resolve(definition); + Assert.Equal(Path.Combine(fixture.AppHostDirectory, ".configgen"), output.LogicalTargetPath); + Assert.StartsWith( + Path.Combine(fixture.StagingPath!, "outputs") + Path.DirectorySeparatorChar, + output.OutputPath, + StringComparison.Ordinal); + Assert.Equal(PipelineOutputExecutionState.Prepared, fixture.Registry.GetExecutionState()); + + var authorizationTask = fixture.Registry.WaitForExecutionAuthorizationAsync(CancellationToken.None); + Assert.False(authorizationTask.IsCompleted); + + fixture.Registry.AuthorizeExecution(); + await authorizationTask; + + fixture.Registry.MarkExecutionSucceeded(); + Assert.Equal(PipelineOutputExecutionState.Succeeded, fixture.Registry.GetExecutionState()); + } + + [Fact] + public void AuthorizeExecution_RejectsStepWithoutRelocationSupport() + { + using var fixture = CreateRegistry(relocate: true); + var step = new PipelineStep + { + Name = "legacy-publisher", + Action = _ => Task.CompletedTask + }; + fixture.Registry.Prepare([step]); + + var exception = Assert.Throws(fixture.Registry.AuthorizeExecution); + + Assert.Contains("'legacy-publisher'", exception.Message); + Assert.Equal(PipelineOutputExecutionState.Prepared, fixture.Registry.GetExecutionState()); + } + + [Fact] + public void Prepare_AppliesConfiguredNamedOutputPath() + { + using var fixture = CreateRegistry( + additionalConfiguration: new Dictionary + { + ["Pipeline:Outputs:config-generator:inventory:Path"] = "../.configgen" + }); + var definition = new PipelineOutputDefinition("inventory", "unused", PipelineOutputKind.Directory); + var step = CreateStep("config-generator", definition); + + fixture.Registry.Prepare([step]); + + var output = new PipelineStepOutputResolver(fixture.Registry, step).Resolve(definition); + Assert.Equal( + Path.GetFullPath(Path.Combine(fixture.AppHostDirectory, "..", ".configgen")), + output.LogicalTargetPath); + } + + [Fact] + public void Prepare_IsDeterministicAndAllowsIdenticalDeclarations() + { + using var fixture = CreateRegistry(); + var second = new PipelineOutputDefinition("second", ".pipelines", PipelineOutputKind.Directory); + var first = new PipelineOutputDefinition("first", ".configgen", PipelineOutputKind.Directory); + var step = CreateStep("publisher", second, first, first); + + fixture.Registry.Prepare([step]); + + Assert.Collection( + fixture.Registry.GetOutputs(), + output => Assert.Equal("aspire/primary", $"{output.PublisherName}/{output.Name}"), + output => Assert.Equal("publisher/first", $"{output.PublisherName}/{output.Name}"), + output => Assert.Equal("publisher/second", $"{output.PublisherName}/{output.Name}")); + } + + [Fact] + public void Prepare_AllowsNamedOutputWithPrimarySyntheticIdentifiers() + { + using var fixture = CreateRegistry(); + var definition = new PipelineOutputDefinition("primary", ".configgen", PipelineOutputKind.Directory); + var step = CreateStep("aspire", definition); + + fixture.Registry.Prepare([step]); + + var primary = fixture.Registry.GetPrimaryOutput(); + var named = new PipelineStepOutputResolver(fixture.Registry, step).Resolve(definition); + Assert.NotSame(primary, named); + Assert.True(primary.IsPrimary); + Assert.False(named.IsPrimary); + Assert.Equal(Path.Combine(fixture.AppHostDirectory, ".configgen"), named.OutputPath); + Assert.Collection( + fixture.Registry.GetOutputs().Where(output => output.PublisherName == "aspire" && output.Name == "primary"), + output => Assert.True(output.IsPrimary), + output => Assert.False(output.IsPrimary)); + } + + [Fact] + public void Prepare_RejectsConflictingDeclarations() + { + using var fixture = CreateRegistry(); + var step = CreateStep( + "publisher", + new PipelineOutputDefinition("inventory", ".configgen", PipelineOutputKind.Directory), + new PipelineOutputDefinition("inventory", ".other-configgen", PipelineOutputKind.Directory)); + + var exception = Assert.Throws(() => fixture.Registry.Prepare([step])); + + Assert.Contains("conflicting metadata", exception.Message); + } + + [Fact] + public void Prepare_RejectsOverlappingNamedOutputs() + { + using var fixture = CreateRegistry(); + var step = CreateStep( + "publisher", + new PipelineOutputDefinition("inventory", ".configgen", PipelineOutputKind.Directory), + new PipelineOutputDefinition("nested", Path.Combine(".configgen", "nested"), PipelineOutputKind.Directory)); + + var exception = Assert.Throws(() => fixture.Registry.Prepare([step])); + + Assert.Contains("overlapping target paths", exception.Message); + } + + [Fact] + public void Prepare_RejectsNamedOutputOverlappingRelocatedPrimaryOutput() + { + using var fixture = CreateRegistry(relocate: true); + var step = CreateStep( + "publisher", + new PipelineOutputDefinition("duplicate", "aspire-output", PipelineOutputKind.Directory)); + + var exception = Assert.Throws(() => fixture.Registry.Prepare([step])); + + Assert.Contains("aspire/primary", exception.Message); + } + + [Fact] + public void Prepare_RejectsNamedOutputEqualToPrimaryOutputWithoutRelocation() + { + using var fixture = CreateRegistry(); + var step = CreateStep( + "publisher", + new PipelineOutputDefinition("duplicate", "aspire-output", PipelineOutputKind.Directory)); + + var exception = Assert.Throws(() => fixture.Registry.Prepare([step])); + + Assert.Contains("aspire/primary", exception.Message); + } + + [Fact] + public void Prepare_AllowsNamedOutputInsidePrimaryOutputWithoutRelocation() + { + using var fixture = CreateRegistry(); + var step = CreateStep( + "publisher", + new PipelineOutputDefinition( + "nested", + Path.Combine("aspire-output", "nested"), + PipelineOutputKind.Directory)); + + fixture.Registry.Prepare([step]); + + Assert.Equal(2, fixture.Registry.GetOutputs().Count); + } + + [Fact] + public void Prepare_RejectsTargetWithWrongKind() + { + using var fixture = CreateRegistry(); + var filePath = Path.Combine(fixture.AppHostDirectory, "existing-file"); + File.WriteAllText(filePath, "content"); + var step = CreateStep( + "publisher", + new PipelineOutputDefinition("inventory", "existing-file", PipelineOutputKind.Directory)); + + var exception = Assert.Throws(() => fixture.Registry.Prepare([step])); + + Assert.Contains("existing file", exception.Message); + } + + [Fact] + public void Resolve_RejectsDefinitionNotInFrozenPlan() + { + using var fixture = CreateRegistry(); + var declared = new PipelineOutputDefinition("inventory", ".configgen", PipelineOutputKind.Directory); + var step = CreateStep("publisher", declared); + fixture.Registry.Prepare([step]); + var undeclared = new PipelineOutputDefinition("pipelines", ".pipelines", PipelineOutputKind.Directory); + + var exception = Assert.Throws( + () => new PipelineStepOutputResolver(fixture.Registry, step).Resolve(undeclared)); + + Assert.Contains("is not declared", exception.Message); + } + + [Fact] + public void Prepare_RejectsSecondPreparation() + { + using var fixture = CreateRegistry(); + fixture.Registry.Prepare([]); + + var exception = Assert.Throws(() => fixture.Registry.Prepare([])); + + Assert.Contains("already been prepared", exception.Message); + } + + [Fact] + public void PrimaryOutput_UsesLogicalTargetKindDuringRelocation() + { + using var fixture = CreateRegistry( + relocate: true, + primaryTargetFileName: "aspire-manifest.json", + pipelineStep: WellKnownPipelineSteps.PublishManifest); + fixture.Registry.Prepare([]); + + var primary = fixture.Registry.GetPrimaryOutput(); + Assert.Equal(PipelineOutputKind.File, primary.Kind); + Assert.Equal(fixture.PrimaryOutputPath, primary.OutputPath); + Assert.Equal(fixture.PrimaryTargetPath, primary.LogicalTargetPath); + } + + [Fact] + public void PrimaryOutput_UsesDirectoryKindForNonManifestJsonTarget() + { + using var fixture = CreateRegistry( + primaryTargetFileName: "artifacts.json", + createPrimaryTargetDirectory: true, + pipelineStep: "publish-docker-compose", + additionalConfiguration: new Dictionary + { + ["Pipeline:Step"] = WellKnownPipelineSteps.PublishManifest + }); + fixture.Registry.Prepare([]); + + var primary = fixture.Registry.GetPrimaryOutput(); + + Assert.Equal(PipelineOutputKind.Directory, primary.Kind); + Assert.Equal(fixture.PrimaryTargetPath, primary.OutputPath); + Assert.Equal(fixture.PrimaryTargetPath, primary.LogicalTargetPath); + } + + [Fact] + public void Prepare_BoundsStagedOutputNamesAndRetainsHashUniqueness() + { + using var fixture = CreateRegistry(relocate: true); + var first = new PipelineOutputDefinition( + $"{new string('o', 300)}a", + "first.json", + PipelineOutputKind.File); + var second = new PipelineOutputDefinition( + $"{new string('o', 300)}b", + "second.json", + PipelineOutputKind.File); + var longExtension = $".{new string('\u754c', 20)}"; + var third = new PipelineOutputDefinition( + "long-extension", + $"third{longExtension}", + PipelineOutputKind.File); + var step = CreateStep(new string('\u754c', 200), first, second, third); + + fixture.Registry.Prepare([step]); + + var resolver = new PipelineStepOutputResolver(fixture.Registry, step); + var firstFileName = Path.GetFileName(resolver.Resolve(first).OutputPath); + var secondFileName = Path.GetFileName(resolver.Resolve(second).OutputPath); + var thirdFileName = Path.GetFileName(resolver.Resolve(third).OutputPath); + Assert.All( + [firstFileName, secondFileName, thirdFileName], + fileName => + { + Assert.InRange( + Encoding.UTF8.GetByteCount(fileName), + 1, + PipelineOutputRegistry.MaximumStagedOutputNameByteCount); + }); + Assert.EndsWith(".json", firstFileName, StringComparison.Ordinal); + Assert.EndsWith(".json", secondFileName, StringComparison.Ordinal); + Assert.EndsWith($".{new string('\u754c', 10)}", thirdFileName, StringComparison.Ordinal); + Assert.NotEqual(firstFileName, secondFileName); + } + + [Fact] + public async Task PipelineExecutor_WaitsForAuthorizationBeforeBeforePublishAndSteps() + { + var bootstrapConfiguration = new ConfigurationBuilder().Build(); + using var fileSystem = new FileSystemService(bootstrapConfiguration); + var root = fileSystem.TempDirectory.CreateTempSubdirectory("pipeline-authorization-tests").Path; + var appHostDirectory = Path.Combine(root, "repo", "src", "AppHost"); + var stagingPath = Path.Combine(root, "staging"); + var primaryTargetPath = Path.Combine(appHostDirectory, "aspire-output"); + var primaryOutputPath = Path.Combine(stagingPath, "primary"); + Directory.CreateDirectory(appHostDirectory); + + using var builder = TestDistributedApplicationBuilder.Create( + options => options.ProjectDirectory = appHostDirectory, + testOutputHelper, + "AppHost:Operation=publish", + "Pipeline:Step=publish", + $"Pipeline:OutputPath={primaryOutputPath}", + $"{PipelineOutputRegistry.StagingPathConfigurationKey}={stagingPath}", + $"{PipelineOutputRegistry.TargetOutputPathConfigurationKey}={primaryTargetPath}"); + + var beforePublish = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var stepExecuted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var publisher = new PipelineStep + { + Name = "publisher", + SupportsOutputPathRelocation = true, + Action = _ => + { + stepExecuted.TrySetResult(); + return Task.CompletedTask; + } + }; + publisher.RequiredBy(WellKnownPipelineSteps.Publish); + builder.Pipeline.AddStep(publisher); + builder.OnBeforePublish((_, _) => + { + // The selected graph is frozen before this event. Mutating the source step here + // must not introduce a cycle into the already-authorized execution plan. + publisher.DependsOn(publisher); + beforePublish.TrySetResult(); + return Task.CompletedTask; + }); + + using var app = builder.Build(); + await app.StartAsync(); + + var registry = app.Services.GetRequiredService(); + await registry.WaitForPreparationAsync(CancellationToken.None).WaitAsync(TimeSpan.FromSeconds(10)); + var rpcTarget = app.Services.GetRequiredService(); + var capabilities = await rpcTarget.GetCapabilitiesAsync(CancellationToken.None); + Assert.Contains("pipeline-outputs.v1", capabilities); + var outputPlan = await rpcTarget.GetPipelineOutputsAsync(cancellationToken: CancellationToken.None); + Assert.Equal(appHostDirectory, outputPlan.AppHostDirectory); + Assert.Equal(nameof(PipelineOutputExecutionState.Prepared), outputPlan.State); + Assert.All(outputPlan.Steps, step => Assert.True(step.SupportsOutputPathRelocation)); + Assert.Collection( + outputPlan.Outputs, + output => + { + Assert.True(output.IsPrimary); + Assert.Equal("aspire", output.PublisherName); + }); + Assert.False(beforePublish.Task.IsCompleted); + Assert.False(stepExecuted.Task.IsCompleted); + + var authorization = await rpcTarget.AuthorizePipelineExecutionAsync(cancellationToken: CancellationToken.None); + Assert.True(authorization.IsAuthorized); + + await beforePublish.Task.WaitAsync(TimeSpan.FromSeconds(10)); + await stepExecuted.Task.WaitAsync(TimeSpan.FromSeconds(10)); + await app.WaitForShutdownAsync().WaitAsync(TimeSpan.FromSeconds(10)); + Assert.Equal(PipelineOutputExecutionState.Succeeded, registry.GetExecutionState()); + } + + [Fact] + public async Task PipelineExecuteAsync_WaitsForAuthorizationBeforeSteps() + { + var bootstrapConfiguration = new ConfigurationBuilder().Build(); + using var fileSystem = new FileSystemService(bootstrapConfiguration); + var root = fileSystem.TempDirectory.CreateTempSubdirectory("direct-pipeline-authorization-tests").Path; + var appHostDirectory = Path.Combine(root, "repo", "src", "AppHost"); + var stagingPath = Path.Combine(root, "staging"); + var primaryTargetPath = Path.Combine(appHostDirectory, "aspire-output"); + var primaryOutputPath = Path.Combine(stagingPath, "primary"); + Directory.CreateDirectory(appHostDirectory); + + using var builder = TestDistributedApplicationBuilder.Create( + options => options.ProjectDirectory = appHostDirectory, + testOutputHelper, + "AppHost:Operation=publish", + "Pipeline:Step=direct-publisher", + $"Pipeline:OutputPath={primaryOutputPath}", + $"{PipelineOutputRegistry.StagingPathConfigurationKey}={stagingPath}", + $"{PipelineOutputRegistry.TargetOutputPathConfigurationKey}={primaryTargetPath}"); + var stepExecuted = false; + builder.Pipeline.AddStep(new PipelineStep + { + Name = "direct-publisher", + SupportsOutputPathRelocation = true, + Action = _ => + { + stepExecuted = true; + return Task.CompletedTask; + } + }); + + using var app = builder.Build(); + var context = new PipelineContext( + app.Services.GetRequiredService(), + app.Services.GetRequiredService(), + app.Services, + NullLogger.Instance, + CancellationToken.None); + var executionTask = builder.Pipeline.ExecuteAsync(context); + + var registry = app.Services.GetRequiredService(); + await registry.WaitForPreparationAsync(CancellationToken.None).WaitAsync(TimeSpan.FromSeconds(10)); + Assert.False(stepExecuted); + Assert.False(executionTask.IsCompleted); + + registry.AuthorizeExecution(); + await executionTask.WaitAsync(TimeSpan.FromSeconds(10)); + Assert.True(stepExecuted); + } + + [Fact] + public async Task ManifestPublishing_WritesOnlyToRelocatedDirectoryOutput() + { + var bootstrapConfiguration = new ConfigurationBuilder().Build(); + using var fileSystem = new FileSystemService(bootstrapConfiguration); + var root = fileSystem.TempDirectory.CreateTempSubdirectory("manifest-relocation-tests").Path; + var appHostDirectory = Path.Combine(root, "repo", "src", "AppHost"); + var stagingPath = Path.Combine(root, "staging"); + var primaryTargetPath = Path.Combine(appHostDirectory, "aspire-output"); + var primaryOutputPath = Path.Combine(stagingPath, "primary"); + Directory.CreateDirectory(appHostDirectory); + + using var builder = TestDistributedApplicationBuilder.Create( + options => options.ProjectDirectory = appHostDirectory, + testOutputHelper, + "AppHost:Operation=publish", + $"Pipeline:Step={WellKnownPipelineSteps.PublishManifest}", + $"Pipeline:OutputPath={primaryOutputPath}", + $"{PipelineOutputRegistry.StagingPathConfigurationKey}={stagingPath}", + $"{PipelineOutputRegistry.TargetOutputPathConfigurationKey}={primaryTargetPath}"); + builder.AddContainer("api", "alpine") + .WithDockerfileBuilder(appHostDirectory, context => context.Builder.From("alpine")); + using var app = builder.Build(); + await app.StartAsync(); + + var registry = app.Services.GetRequiredService(); + await registry.WaitForPreparationAsync(CancellationToken.None).WaitAsync(TimeSpan.FromSeconds(10)); + var outputPlan = await app.Services.GetRequiredService() + .GetPipelineOutputsAsync(cancellationToken: CancellationToken.None); + var manifestStep = Assert.Single( + outputPlan.Steps, + step => step.Name == WellKnownPipelineSteps.PublishManifest); + Assert.True(manifestStep.SupportsOutputPathRelocation); + + registry.AuthorizeExecution(); + await app.WaitForShutdownAsync().WaitAsync(TimeSpan.FromSeconds(10)); + + var generatedManifestPath = Path.Combine(primaryOutputPath, "aspire-manifest.json"); + var logicalManifestPath = Path.Combine(primaryTargetPath, "aspire-manifest.json"); + var generatedDockerfilePath = Path.Combine(Path.GetDirectoryName(generatedManifestPath)!, "api.Dockerfile"); + var logicalDockerfilePath = Path.Combine(Path.GetDirectoryName(logicalManifestPath)!, "api.Dockerfile"); + Assert.True(File.Exists(generatedManifestPath)); + Assert.True(File.Exists(generatedDockerfilePath)); + Assert.False(File.Exists(logicalDockerfilePath)); + Assert.False(File.Exists(primaryTargetPath)); + Assert.False(Directory.Exists(primaryTargetPath)); + + using var manifest = JsonDocument.Parse(await File.ReadAllTextAsync(generatedManifestPath)); + var build = manifest.RootElement.GetProperty("resources").GetProperty("api").GetProperty("build"); + Assert.Equal("api.Dockerfile", build.GetProperty("dockerfile").GetString()); + Assert.Equal( + Path.GetRelativePath(Path.GetDirectoryName(logicalManifestPath)!, appHostDirectory).Replace('\\', '/'), + build.GetProperty("context").GetString()); + Assert.Equal(PipelineOutputExecutionState.Succeeded, registry.GetExecutionState()); + } + + [Fact] + public async Task ManifestPublishing_FileOutputDoesNotSupportRelocation() + { + var bootstrapConfiguration = new ConfigurationBuilder().Build(); + using var fileSystem = new FileSystemService(bootstrapConfiguration); + var root = fileSystem.TempDirectory.CreateTempSubdirectory("manifest-file-relocation-tests").Path; + var appHostDirectory = Path.Combine(root, "repo", "src", "AppHost"); + var stagingPath = Path.Combine(root, "staging"); + var primaryTargetPath = Path.Combine(appHostDirectory, "aspire-manifest.json"); + var primaryOutputPath = Path.Combine(stagingPath, "primary"); + Directory.CreateDirectory(appHostDirectory); + + using var builder = TestDistributedApplicationBuilder.Create( + options => options.ProjectDirectory = appHostDirectory, + testOutputHelper, + "AppHost:Operation=publish", + $"Pipeline:Step={WellKnownPipelineSteps.PublishManifest}", + $"Pipeline:OutputPath={primaryOutputPath}", + $"{PipelineOutputRegistry.StagingPathConfigurationKey}={stagingPath}", + $"{PipelineOutputRegistry.TargetOutputPathConfigurationKey}={primaryTargetPath}"); + builder.AddContainer("api", "alpine") + .WithDockerfileBuilder(appHostDirectory, context => context.Builder.From("alpine")); + using var app = builder.Build(); + await app.StartAsync(); + + var registry = app.Services.GetRequiredService(); + await registry.WaitForPreparationAsync(CancellationToken.None).WaitAsync(TimeSpan.FromSeconds(10)); + var outputPlan = await app.Services.GetRequiredService() + .GetPipelineOutputsAsync(cancellationToken: CancellationToken.None); + var manifestStep = Assert.Single( + outputPlan.Steps, + step => step.Name == WellKnownPipelineSteps.PublishManifest); + Assert.False(manifestStep.SupportsOutputPathRelocation); + + var exception = Assert.Throws(registry.AuthorizeExecution); + Assert.Contains($"'{WellKnownPipelineSteps.PublishManifest}'", exception.Message); + Assert.False(File.Exists(primaryTargetPath)); + Assert.False(File.Exists(Path.Combine(appHostDirectory, "api.Dockerfile"))); + + await app.StopAsync(); + } + + [Fact] + public async Task NamedPublishing_WritesOnlyToRelocatedOutputPaths() + { + var bootstrapConfiguration = new ConfigurationBuilder().Build(); + using var fileSystem = new FileSystemService(bootstrapConfiguration); + var root = fileSystem.TempDirectory.CreateTempSubdirectory("named-output-relocation-tests").Path; + var appHostDirectory = Path.Combine(root, "repo", "src", "AppHost"); + var stagingPath = Path.Combine(root, "staging"); + var primaryTargetPath = Path.Combine(appHostDirectory, "aspire-output"); + var primaryOutputPath = Path.Combine(stagingPath, "primary"); + Directory.CreateDirectory(appHostDirectory); + + var inventory = new PipelineOutputDefinition("inventory", ".configgen", PipelineOutputKind.Directory); + var pipelines = new PipelineOutputDefinition("pipelines", ".pipelines", PipelineOutputKind.Directory); + + using var builder = TestDistributedApplicationBuilder.Create( + options => options.ProjectDirectory = appHostDirectory, + testOutputHelper, + "AppHost:Operation=publish", + "Pipeline:Step=publish", + $"Pipeline:OutputPath={primaryOutputPath}", + $"{PipelineOutputRegistry.StagingPathConfigurationKey}={stagingPath}", + $"{PipelineOutputRegistry.TargetOutputPathConfigurationKey}={primaryTargetPath}"); + var publisher = new PipelineStep + { + Name = "config-generator", + Outputs = [inventory, pipelines], + SupportsOutputPathRelocation = true, + Action = context => + { + foreach (var output in new[] { inventory, pipelines }) + { + var resolved = context.Outputs.Resolve(output); + Directory.CreateDirectory(resolved.OutputPath); + File.WriteAllText(Path.Combine(resolved.OutputPath, "generated.txt"), resolved.LogicalTargetPath); + } + + return Task.CompletedTask; + } + }; + publisher.RequiredBy(WellKnownPipelineSteps.Publish); + builder.Pipeline.AddStep(publisher); + + using var app = builder.Build(); + await app.StartAsync(); + + var registry = app.Services.GetRequiredService(); + await registry.WaitForPreparationAsync(CancellationToken.None).WaitAsync(TimeSpan.FromSeconds(10)); + + var namedOutputs = registry.GetOutputs() + .Where(output => output.PublisherName == publisher.Name) + .ToDictionary(output => output.Name, StringComparer.Ordinal); + Assert.Equal(Path.Combine(appHostDirectory, ".configgen"), namedOutputs["inventory"].LogicalTargetPath); + Assert.Equal(Path.Combine(appHostDirectory, ".pipelines"), namedOutputs["pipelines"].LogicalTargetPath); + + registry.AuthorizeExecution(); + await app.WaitForShutdownAsync().WaitAsync(TimeSpan.FromSeconds(10)); + + Assert.True(File.Exists(Path.Combine(namedOutputs["inventory"].OutputPath, "generated.txt"))); + Assert.True(File.Exists(Path.Combine(namedOutputs["pipelines"].OutputPath, "generated.txt"))); + Assert.False(Directory.Exists(Path.Combine(appHostDirectory, ".configgen"))); + Assert.False(Directory.Exists(Path.Combine(appHostDirectory, ".pipelines"))); + Assert.Equal(PipelineOutputExecutionState.Succeeded, registry.GetExecutionState()); + } + + private static PipelineStep CreateStep(string name, params PipelineOutputDefinition[] outputs) + { + return new PipelineStep + { + Name = name, + Action = _ => Task.CompletedTask, + Outputs = [.. outputs], + SupportsOutputPathRelocation = true + }; + } + + private static RegistryFixture CreateRegistry( + bool relocate = false, + string? primaryTargetFileName = null, + bool createPrimaryTargetDirectory = false, + string? pipelineStep = null, + IReadOnlyDictionary? additionalConfiguration = null) + { + var bootstrapConfiguration = new ConfigurationBuilder().Build(); + var fileSystem = new FileSystemService(bootstrapConfiguration); + var root = fileSystem.TempDirectory.CreateTempSubdirectory("pipeline-output-tests").Path; + var appHostDirectory = Path.Combine(root, "repo", "src", "AppHost"); + Directory.CreateDirectory(appHostDirectory); + + var primaryTargetPath = Path.Combine( + appHostDirectory, + primaryTargetFileName ?? "aspire-output"); + var stagingPath = relocate ? Path.Combine(root, "staging") : null; + var primaryOutputPath = relocate + ? Path.Combine(stagingPath!, primaryTargetFileName ?? "primary") + : primaryTargetPath; + if (createPrimaryTargetDirectory) + { + Directory.CreateDirectory(primaryTargetPath); + } + + var configurationValues = new Dictionary + { + ["AppHost:Directory"] = appHostDirectory + }; + + if (relocate) + { + configurationValues[PipelineOutputRegistry.StagingPathConfigurationKey] = stagingPath; + configurationValues[PipelineOutputRegistry.TargetOutputPathConfigurationKey] = primaryTargetPath; + } + + if (additionalConfiguration is not null) + { + foreach (var (key, value) in additionalConfiguration) + { + configurationValues[key] = value; + } + } + + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(configurationValues) + .Build(); + var pipelineOptions = Options.Create(new PipelineOptions + { + OutputPath = primaryOutputPath, + Step = pipelineStep + }); + var outputService = new PipelineOutputService(pipelineOptions, configuration, fileSystem); + + return new RegistryFixture( + new PipelineOutputRegistry(configuration, outputService, pipelineOptions), + fileSystem, + appHostDirectory, + primaryOutputPath, + primaryTargetPath, + stagingPath); + } + + private sealed class RegistryFixture( + PipelineOutputRegistry registry, + FileSystemService fileSystem, + string appHostDirectory, + string primaryOutputPath, + string primaryTargetPath, + string? stagingPath) : IDisposable + { + public PipelineOutputRegistry Registry { get; } = registry; + + public string AppHostDirectory { get; } = appHostDirectory; + + public string PrimaryOutputPath { get; } = primaryOutputPath; + + public string PrimaryTargetPath { get; } = primaryTargetPath; + + public string? StagingPath { get; } = stagingPath; + + public void Dispose() + { + fileSystem.Dispose(); + } + } +}