diff --git a/src/Aspire.Cli/Builds/AppHostBuilder.cs b/src/Aspire.Cli/Builds/AppHostBuilder.cs new file mode 100644 index 00000000000..cd71ce2dc4c --- /dev/null +++ b/src/Aspire.Cli/Builds/AppHostBuilder.cs @@ -0,0 +1,88 @@ +// 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; +using System.Security.Cryptography; +using System.Text; +using Microsoft.Extensions.Logging; + +namespace Aspire.Cli.Builds; + +internal interface IAppHostBuilder +{ + Task BuildAppHostAsync(FileInfo projectFile, bool useCache, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken); +} + +internal sealed class AppHostBuilder(ILogger logger, IDotNetCliRunner runner) : IAppHostBuilder +{ + private readonly ActivitySource _activitySource = new ActivitySource(nameof(AppHostBuilder)); + private readonly SHA256 _sha256 = SHA256.Create(); + + private async Task GetBuildFingerprintAsync(FileInfo projectFile, CancellationToken cancellationToken) + { + using var activity = _activitySource.StartActivity(); + + _ = logger; + + var msBuildResult = await runner.GetProjectItemsAndPropertiesAsync( + projectFile, + ["ProjectReference", "PackageReference", "Compile"], + ["OutputPath"], + new DotNetCliRunnerInvocationOptions(), + cancellationToken + ); + + var json = msBuildResult.Output?.RootElement.ToString(); + + var jsonBytes = Encoding.UTF8.GetBytes(json!); + var hash = _sha256.ComputeHash(jsonBytes); + var hashString = Convert.ToHexString(hash); + + return hashString; + } + + private string GetAppHostStateBasePath(FileInfo projectFile) + { + var fullPath = projectFile.FullName; + var fullPathBytes = Encoding.UTF8.GetBytes(fullPath); + var hash = _sha256.ComputeHash(fullPathBytes); + var hashString = Convert.ToHexString(hash); + + var homeDirectory = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + var appHostStatePath = Path.Combine(homeDirectory, ".aspire", "apphosts", hashString); + + if (Directory.Exists(appHostStatePath)) + { + return appHostStatePath; + } + else + { + Directory.CreateDirectory(appHostStatePath); + return appHostStatePath; + } + } + + public async Task BuildAppHostAsync(FileInfo projectFile, bool useCache, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken) + { + using var activity = _activitySource.StartActivity(); + + var currentFingerprint = await GetBuildFingerprintAsync(projectFile, cancellationToken); + var appHostStatePath = GetAppHostStateBasePath(projectFile); + var buildFingerprintFile = Path.Combine(appHostStatePath, "fingerprint.txt"); + + if (File.Exists(buildFingerprintFile) && useCache) + { + var lastFingerprint = await File.ReadAllTextAsync(buildFingerprintFile, cancellationToken); + if (lastFingerprint == currentFingerprint) + { + return 0; + } + } + + var exitCode = await runner.BuildAsync(projectFile, options, cancellationToken); + + await File.WriteAllTextAsync(buildFingerprintFile, currentFingerprint, cancellationToken); + + return exitCode; + } +} \ No newline at end of file diff --git a/src/Aspire.Cli/Commands/PublishCommand.cs b/src/Aspire.Cli/Commands/PublishCommand.cs index 0c085a63e86..3c67cc04f34 100644 --- a/src/Aspire.Cli/Commands/PublishCommand.cs +++ b/src/Aspire.Cli/Commands/PublishCommand.cs @@ -4,6 +4,7 @@ using System.CommandLine; using System.Diagnostics; using Aspire.Cli.Backchannel; +using Aspire.Cli.Builds; using Aspire.Cli.Interaction; using Aspire.Cli.Projects; using Aspire.Cli.Utils; @@ -37,19 +38,22 @@ internal sealed class PublishCommand : BaseCommand private readonly IInteractionService _interactionService; private readonly IProjectLocator _projectLocator; private readonly IPublishCommandPrompter _prompter; + private readonly IAppHostBuilder _appHostBuilder; - public PublishCommand(IDotNetCliRunner runner, IInteractionService interactionService, IProjectLocator projectLocator, IPublishCommandPrompter prompter) + public PublishCommand(IDotNetCliRunner runner, IInteractionService interactionService, IProjectLocator projectLocator, IPublishCommandPrompter prompter, IAppHostBuilder appHostBuilder) : base("publish", "Generates deployment artifacts for an Aspire app host project.") { ArgumentNullException.ThrowIfNull(runner); ArgumentNullException.ThrowIfNull(interactionService); ArgumentNullException.ThrowIfNull(projectLocator); ArgumentNullException.ThrowIfNull(prompter); + ArgumentNullException.ThrowIfNull(appHostBuilder); _runner = runner; _interactionService = interactionService; _projectLocator = projectLocator; _prompter = prompter; + _appHostBuilder = appHostBuilder; var projectOption = new Option("--project"); projectOption.Description = "The path to the Aspire app host project file."; @@ -63,6 +67,10 @@ public PublishCommand(IDotNetCliRunner runner, IInteractionService interactionSe outputPath.Description = "The output path for the generated artifacts."; outputPath.DefaultValueFactory = (result) => Path.Combine(Environment.CurrentDirectory); Options.Add(outputPath); + + var noCacheOption = new Option("--no-cache", "-nc"); + noCacheOption.Description = "Do not use cached build of the app host."; + Options.Add(noCacheOption); } protected override async Task ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken) @@ -106,7 +114,9 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell StandardErrorCallback = outputCollector.AppendError, }; - var buildExitCode = await AppHostHelper.BuildAppHostAsync(_runner, _interactionService, effectiveAppHostProjectFile, buildOptions, cancellationToken); + var useCache = !parseResult.GetValue("--no-cache"); + + var buildExitCode = await AppHostHelper.BuildAppHostAsync(_appHostBuilder, useCache, _interactionService, effectiveAppHostProjectFile, buildOptions, cancellationToken); if (buildExitCode != 0) { diff --git a/src/Aspire.Cli/Commands/RunCommand.cs b/src/Aspire.Cli/Commands/RunCommand.cs index fc165401589..85e5eb400c5 100644 --- a/src/Aspire.Cli/Commands/RunCommand.cs +++ b/src/Aspire.Cli/Commands/RunCommand.cs @@ -4,6 +4,7 @@ using System.CommandLine; using System.Diagnostics; using Aspire.Cli.Backchannel; +using Aspire.Cli.Builds; using Aspire.Cli.Certificates; using Aspire.Cli.Interaction; using Aspire.Cli.Projects; @@ -23,8 +24,9 @@ internal sealed class RunCommand : BaseCommand private readonly ICertificateService _certificateService; private readonly IProjectLocator _projectLocator; private readonly IAnsiConsole _ansiConsole; + private readonly IAppHostBuilder _appHostBuilder; - public RunCommand(IDotNetCliRunner runner, IInteractionService interactionService, ICertificateService certificateService, IProjectLocator projectLocator, IAnsiConsole ansiConsole) + public RunCommand(IDotNetCliRunner runner, IInteractionService interactionService, ICertificateService certificateService, IProjectLocator projectLocator, IAnsiConsole ansiConsole, IAppHostBuilder appHostBuilder) : base("run", "Run an Aspire app host in development mode.") { ArgumentNullException.ThrowIfNull(runner); @@ -32,12 +34,14 @@ public RunCommand(IDotNetCliRunner runner, IInteractionService interactionServic ArgumentNullException.ThrowIfNull(certificateService); ArgumentNullException.ThrowIfNull(projectLocator); ArgumentNullException.ThrowIfNull(ansiConsole); + ArgumentNullException.ThrowIfNull(appHostBuilder); _runner = runner; _interactionService = interactionService; _certificateService = certificateService; _projectLocator = projectLocator; _ansiConsole = ansiConsole; + _appHostBuilder = appHostBuilder; var projectOption = new Option("--project"); projectOption.Description = "The path to the Aspire app host project file."; @@ -46,6 +50,10 @@ public RunCommand(IDotNetCliRunner runner, IInteractionService interactionServic var watchOption = new Option("--watch", "-w"); watchOption.Description = "Start project resources in watch mode."; Options.Add(watchOption); + + var noCacheOption = new Option("--no-cache", "-nc"); + noCacheOption.Description = "Do not use cached build of the app host."; + Options.Add(noCacheOption); } protected override async Task ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken) @@ -89,13 +97,15 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell if (!watch) { + var useCache = !parseResult.GetValue("--no-cache"); + var buildOptions = new DotNetCliRunnerInvocationOptions { StandardOutputCallback = outputCollector.AppendOutput, StandardErrorCallback = outputCollector.AppendError, }; - var buildExitCode = await AppHostHelper.BuildAppHostAsync(_runner, _interactionService, effectiveAppHostProjectFile, buildOptions, cancellationToken); + var buildExitCode = await AppHostHelper.BuildAppHostAsync(_appHostBuilder, useCache, _interactionService, effectiveAppHostProjectFile, buildOptions, cancellationToken); if (buildExitCode != 0) { diff --git a/src/Aspire.Cli/DotNetCliRunner.cs b/src/Aspire.Cli/DotNetCliRunner.cs index 705217b8185..c9def649ff1 100644 --- a/src/Aspire.Cli/DotNetCliRunner.cs +++ b/src/Aspire.Cli/DotNetCliRunner.cs @@ -331,7 +331,7 @@ public async Task NewProjectAsync(string templateName, string name, string internal static string GetBackchannelSocketPath() { var homeDirectory = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); - var dotnetCliPath = Path.Combine(homeDirectory, ".dotnet", "aspire", "cli", "backchannels"); + var dotnetCliPath = Path.Combine(homeDirectory, ".aspire", "cli", "backchannels"); if (!Directory.Exists(dotnetCliPath)) { diff --git a/src/Aspire.Cli/Program.cs b/src/Aspire.Cli/Program.cs index 2d7252ac396..afea10276a0 100644 --- a/src/Aspire.Cli/Program.cs +++ b/src/Aspire.Cli/Program.cs @@ -14,6 +14,7 @@ using Microsoft.Extensions.Logging; using Spectre.Console; using Microsoft.Extensions.Configuration; +using Aspire.Cli.Builds; #if DEBUG using OpenTelemetry; @@ -120,7 +121,8 @@ private static IHost BuildApplication(string[] args) builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); - builder.Services.AddTransient(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); builder.Services.AddTransient(); builder.Services.AddSingleton(); builder.Services.AddTransient(); diff --git a/src/Aspire.Cli/Utils/AppHostHelper.cs b/src/Aspire.Cli/Utils/AppHostHelper.cs index 8c3b1ad4fe4..ef4aa4866ea 100644 --- a/src/Aspire.Cli/Utils/AppHostHelper.cs +++ b/src/Aspire.Cli/Utils/AppHostHelper.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using Aspire.Cli.Builds; using Aspire.Cli.Interaction; using Semver; using System.Diagnostics; @@ -61,13 +62,14 @@ internal static class AppHostHelper return appHostInformationResult; } - internal static async Task BuildAppHostAsync(IDotNetCliRunner runner, IInteractionService interactionService, FileInfo projectFile, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken) + internal static async Task BuildAppHostAsync(IAppHostBuilder builder, bool useCache, IInteractionService interactionService, FileInfo projectFile, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken) { return await interactionService.ShowStatusAsync( ":hammer_and_wrench: Building app host...", - () => runner.BuildAsync( - projectFile, - options, - cancellationToken)); + () => builder.BuildAppHostAsync( + projectFile, + useCache, + options, + cancellationToken)); } } \ No newline at end of file diff --git a/tests/Aspire.Cli.Tests/TestServices/TestDotNetCliRunner.cs b/tests/Aspire.Cli.Tests/TestServices/TestDotNetCliRunner.cs index b0554d76677..144f3d55202 100644 --- a/tests/Aspire.Cli.Tests/TestServices/TestDotNetCliRunner.cs +++ b/tests/Aspire.Cli.Tests/TestServices/TestDotNetCliRunner.cs @@ -50,11 +50,25 @@ public Task CheckHttpCertificateAsync(DotNetCliRunnerInvocationOptions opti : Task.FromResult<(int, bool, string?)>((0, true, informationalVersion)); } + private static JsonDocument GetProjectItemsAndPropertiesJsonDocument() + { + var json = $$""" + { + "CacheBuster": "{{Guid.NewGuid().ToString()}}", + "ProjectReference": [], + "PackageReference": [], + "Compile": [] + } + """; + + return JsonDocument.Parse(json); + } + public Task<(int ExitCode, JsonDocument? Output)> GetProjectItemsAndPropertiesAsync(FileInfo projectFile, string[] items, string[] properties, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken) { return GetProjectItemsAndPropertiesAsyncCallback != null ? Task.FromResult(GetProjectItemsAndPropertiesAsyncCallback(projectFile, items, properties, options, cancellationToken)) - : throw new NotImplementedException(); + : Task.FromResult<(int, JsonDocument?)>((0, GetProjectItemsAndPropertiesJsonDocument())); } public Task<(int ExitCode, string? TemplateVersion)> InstallTemplateAsync(string packageName, string version, string? nugetSource, bool force, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken) diff --git a/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs b/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs index 9b0c20f0dd6..988de40b293 100644 --- a/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs +++ b/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs @@ -3,6 +3,7 @@ using System.Text; using Aspire.Cli.Backchannel; +using Aspire.Cli.Builds; using Aspire.Cli.Certificates; using Aspire.Cli.Commands; using Aspire.Cli.Interaction; @@ -29,6 +30,7 @@ public static IServiceCollection CreateServiceCollection(ITestOutputHelper outpu services.AddLogging(); + services.AddSingleton(options.AppHostBuilderFactory); services.AddSingleton(options.AnsiConsoleFactory); services.AddSingleton(options.ProjectLocatorFactory); services.AddSingleton(options.InteractionServiceFactory); @@ -36,7 +38,7 @@ public static IServiceCollection CreateServiceCollection(ITestOutputHelper outpu services.AddSingleton(options.NewCommandPrompterFactory); services.AddSingleton(options.AddCommandPrompterFactory); services.AddSingleton(options.PublishCommandPrompterFactory); - services.AddTransient(options.DotNetCliRunnerFactory); + services.AddSingleton(options.DotNetCliRunnerFactory); services.AddTransient(options.NuGetPackageCacheFactory); services.AddTransient(); services.AddTransient(); @@ -51,6 +53,12 @@ public static IServiceCollection CreateServiceCollection(ITestOutputHelper outpu internal sealed class CliServiceCollectionTestOptions(ITestOutputHelper outputHelper) { + public Func AppHostBuilderFactory { get; set; } = (IServiceProvider serviceProvider) => { + var logger = serviceProvider.GetRequiredService>(); + var runner = serviceProvider.GetRequiredService(); + return new AppHostBuilder(logger, runner); + }; + public Func AnsiConsoleFactory { get; set; } = (IServiceProvider serviceProvider) => { AnsiConsoleSettings settings = new AnsiConsoleSettings()