Skip to content

Commit a4eb146

Browse files
committed
Build cache take 2.
1 parent 0eafb36 commit a4eb146

File tree

7 files changed

+132
-12
lines changed

7 files changed

+132
-12
lines changed
+88
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using System.Diagnostics;
5+
using System.Security.Cryptography;
6+
using System.Text;
7+
using Microsoft.Extensions.Logging;
8+
9+
namespace Aspire.Cli.Builds;
10+
11+
internal interface IAppHostBuilder
12+
{
13+
Task<int> BuildAppHostAsync(FileInfo projectFile, bool useCache, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken);
14+
}
15+
16+
internal sealed class AppHostBuilder(ILogger<AppHostBuilder> logger, IDotNetCliRunner runner) : IAppHostBuilder
17+
{
18+
private readonly ActivitySource _activitySource = new ActivitySource(nameof(AppHostBuilder));
19+
private readonly SHA256 _sha256 = SHA256.Create();
20+
21+
private async Task<string> GetBuildFingerprintAsync(FileInfo projectFile, CancellationToken cancellationToken)
22+
{
23+
using var activity = _activitySource.StartActivity();
24+
25+
_ = logger;
26+
27+
var msBuildResult = await runner.GetProjectItemsAndPropertiesAsync(
28+
projectFile,
29+
["ProjectReference", "PackageReference", "Compile"],
30+
["OutputPath"],
31+
new DotNetCliRunnerInvocationOptions(),
32+
cancellationToken
33+
);
34+
35+
var json = msBuildResult.Output?.RootElement.ToString();
36+
37+
var jsonBytes = Encoding.UTF8.GetBytes(json!);
38+
var hash = _sha256.ComputeHash(jsonBytes);
39+
var hashString = Convert.ToHexString(hash);
40+
41+
return hashString;
42+
}
43+
44+
private string GetAppHostStateBasePath(FileInfo projectFile)
45+
{
46+
var fullPath = projectFile.FullName;
47+
var fullPathBytes = Encoding.UTF8.GetBytes(fullPath);
48+
var hash = _sha256.ComputeHash(fullPathBytes);
49+
var hashString = Convert.ToHexString(hash);
50+
51+
var homeDirectory = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
52+
var appHostStatePath = Path.Combine(homeDirectory, ".aspire", "apphosts", hashString);
53+
54+
if (Directory.Exists(appHostStatePath))
55+
{
56+
return appHostStatePath;
57+
}
58+
else
59+
{
60+
Directory.CreateDirectory(appHostStatePath);
61+
return appHostStatePath;
62+
}
63+
}
64+
65+
public async Task<int> BuildAppHostAsync(FileInfo projectFile, bool useCache, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken)
66+
{
67+
using var activity = _activitySource.StartActivity();
68+
69+
var currentFingerprint = await GetBuildFingerprintAsync(projectFile, cancellationToken);
70+
var appHostStatePath = GetAppHostStateBasePath(projectFile);
71+
var buildFingerprintFile = Path.Combine(appHostStatePath, "fingerprint.txt");
72+
73+
if (File.Exists(buildFingerprintFile) && useCache)
74+
{
75+
var lastFingerprint = await File.ReadAllTextAsync(buildFingerprintFile, cancellationToken);
76+
if (lastFingerprint == currentFingerprint)
77+
{
78+
return 0;
79+
}
80+
}
81+
82+
var exitCode = await runner.BuildAsync(projectFile, options, cancellationToken);
83+
84+
await File.WriteAllTextAsync(buildFingerprintFile, currentFingerprint, cancellationToken);
85+
86+
return exitCode;
87+
}
88+
}

src/Aspire.Cli/Commands/PublishCommand.cs

+12-2
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
using System.CommandLine;
55
using System.Diagnostics;
66
using Aspire.Cli.Backchannel;
7+
using Aspire.Cli.Builds;
78
using Aspire.Cli.Interaction;
89
using Aspire.Cli.Projects;
910
using Aspire.Cli.Utils;
@@ -37,19 +38,22 @@ internal sealed class PublishCommand : BaseCommand
3738
private readonly IInteractionService _interactionService;
3839
private readonly IProjectLocator _projectLocator;
3940
private readonly IPublishCommandPrompter _prompter;
41+
private readonly IAppHostBuilder _appHostBuilder;
4042

41-
public PublishCommand(IDotNetCliRunner runner, IInteractionService interactionService, IProjectLocator projectLocator, IPublishCommandPrompter prompter)
43+
public PublishCommand(IDotNetCliRunner runner, IInteractionService interactionService, IProjectLocator projectLocator, IPublishCommandPrompter prompter, IAppHostBuilder appHostBuilder)
4244
: base("publish", "Generates deployment artifacts for an Aspire app host project.")
4345
{
4446
ArgumentNullException.ThrowIfNull(runner);
4547
ArgumentNullException.ThrowIfNull(interactionService);
4648
ArgumentNullException.ThrowIfNull(projectLocator);
4749
ArgumentNullException.ThrowIfNull(prompter);
50+
ArgumentNullException.ThrowIfNull(appHostBuilder);
4851

4952
_runner = runner;
5053
_interactionService = interactionService;
5154
_projectLocator = projectLocator;
5255
_prompter = prompter;
56+
_appHostBuilder = appHostBuilder;
5357

5458
var projectOption = new Option<FileInfo?>("--project");
5559
projectOption.Description = "The path to the Aspire app host project file.";
@@ -63,6 +67,10 @@ public PublishCommand(IDotNetCliRunner runner, IInteractionService interactionSe
6367
outputPath.Description = "The output path for the generated artifacts.";
6468
outputPath.DefaultValueFactory = (result) => Path.Combine(Environment.CurrentDirectory);
6569
Options.Add(outputPath);
70+
71+
var noCacheOption = new Option<bool>("--no-cache", "-nc");
72+
noCacheOption.Description = "Do not use cached build of the app host.";
73+
Options.Add(noCacheOption);
6674
}
6775

6876
protected override async Task<int> ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken)
@@ -106,7 +114,9 @@ protected override async Task<int> ExecuteAsync(ParseResult parseResult, Cancell
106114
StandardErrorCallback = outputCollector.AppendError,
107115
};
108116

109-
var buildExitCode = await AppHostHelper.BuildAppHostAsync(_runner, _interactionService, effectiveAppHostProjectFile, buildOptions, cancellationToken);
117+
var useCache = !parseResult.GetValue<bool>("--no-cache");
118+
119+
var buildExitCode = await AppHostHelper.BuildAppHostAsync(_appHostBuilder, useCache, _interactionService, effectiveAppHostProjectFile, buildOptions, cancellationToken);
110120

111121
if (buildExitCode != 0)
112122
{

src/Aspire.Cli/Commands/RunCommand.cs

+12-2
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
using System.CommandLine;
55
using System.Diagnostics;
66
using Aspire.Cli.Backchannel;
7+
using Aspire.Cli.Builds;
78
using Aspire.Cli.Certificates;
89
using Aspire.Cli.Interaction;
910
using Aspire.Cli.Projects;
@@ -23,21 +24,24 @@ internal sealed class RunCommand : BaseCommand
2324
private readonly ICertificateService _certificateService;
2425
private readonly IProjectLocator _projectLocator;
2526
private readonly IAnsiConsole _ansiConsole;
27+
private readonly IAppHostBuilder _appHostBuilder;
2628

27-
public RunCommand(IDotNetCliRunner runner, IInteractionService interactionService, ICertificateService certificateService, IProjectLocator projectLocator, IAnsiConsole ansiConsole)
29+
public RunCommand(IDotNetCliRunner runner, IInteractionService interactionService, ICertificateService certificateService, IProjectLocator projectLocator, IAnsiConsole ansiConsole, IAppHostBuilder appHostBuilder)
2830
: base("run", "Run an Aspire app host in development mode.")
2931
{
3032
ArgumentNullException.ThrowIfNull(runner);
3133
ArgumentNullException.ThrowIfNull(interactionService);
3234
ArgumentNullException.ThrowIfNull(certificateService);
3335
ArgumentNullException.ThrowIfNull(projectLocator);
3436
ArgumentNullException.ThrowIfNull(ansiConsole);
37+
ArgumentNullException.ThrowIfNull(appHostBuilder);
3538

3639
_runner = runner;
3740
_interactionService = interactionService;
3841
_certificateService = certificateService;
3942
_projectLocator = projectLocator;
4043
_ansiConsole = ansiConsole;
44+
_appHostBuilder = appHostBuilder;
4145

4246
var projectOption = new Option<FileInfo?>("--project");
4347
projectOption.Description = "The path to the Aspire app host project file.";
@@ -46,6 +50,10 @@ public RunCommand(IDotNetCliRunner runner, IInteractionService interactionServic
4650
var watchOption = new Option<bool>("--watch", "-w");
4751
watchOption.Description = "Start project resources in watch mode.";
4852
Options.Add(watchOption);
53+
54+
var noCacheOption = new Option<bool>("--no-cache", "-nc");
55+
noCacheOption.Description = "Do not use cached build of the app host.";
56+
Options.Add(noCacheOption);
4957
}
5058

5159
protected override async Task<int> ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken)
@@ -89,13 +97,15 @@ protected override async Task<int> ExecuteAsync(ParseResult parseResult, Cancell
8997

9098
if (!watch)
9199
{
100+
var useCache = !parseResult.GetValue<bool>("--no-cache");
101+
92102
var buildOptions = new DotNetCliRunnerInvocationOptions
93103
{
94104
StandardOutputCallback = outputCollector.AppendOutput,
95105
StandardErrorCallback = outputCollector.AppendError,
96106
};
97107

98-
var buildExitCode = await AppHostHelper.BuildAppHostAsync(_runner, _interactionService, effectiveAppHostProjectFile, buildOptions, cancellationToken);
108+
var buildExitCode = await AppHostHelper.BuildAppHostAsync(_appHostBuilder, useCache, _interactionService, effectiveAppHostProjectFile, buildOptions, cancellationToken);
99109

100110
if (buildExitCode != 0)
101111
{

src/Aspire.Cli/DotNetCliRunner.cs

+1-1
Original file line numberDiff line numberDiff line change
@@ -331,7 +331,7 @@ public async Task<int> NewProjectAsync(string templateName, string name, string
331331
internal static string GetBackchannelSocketPath()
332332
{
333333
var homeDirectory = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
334-
var dotnetCliPath = Path.Combine(homeDirectory, ".dotnet", "aspire", "cli", "backchannels");
334+
var dotnetCliPath = Path.Combine(homeDirectory, ".aspire", "cli", "backchannels");
335335

336336
if (!Directory.Exists(dotnetCliPath))
337337
{

src/Aspire.Cli/Program.cs

+3-1
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
using Microsoft.Extensions.Logging;
1515
using Spectre.Console;
1616
using Microsoft.Extensions.Configuration;
17+
using Aspire.Cli.Builds;
1718

1819
#if DEBUG
1920
using OpenTelemetry;
@@ -120,7 +121,8 @@ private static IHost BuildApplication(string[] args)
120121
builder.Services.AddSingleton<IPublishCommandPrompter, PublishCommandPrompter>();
121122
builder.Services.AddSingleton<IInteractionService, InteractionService>();
122123
builder.Services.AddSingleton<ICertificateService, CertificateService>();
123-
builder.Services.AddTransient<IDotNetCliRunner, DotNetCliRunner>();
124+
builder.Services.AddSingleton<IAppHostBuilder, AppHostBuilder>();
125+
builder.Services.AddSingleton<IDotNetCliRunner, DotNetCliRunner>();
124126
builder.Services.AddTransient<IAppHostBackchannel, AppHostBackchannel>();
125127
builder.Services.AddSingleton<CliRpcTarget>();
126128
builder.Services.AddTransient<INuGetPackageCache, NuGetPackageCache>();

src/Aspire.Cli/Utils/AppHostHelper.cs

+7-5
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// Licensed to the .NET Foundation under one or more agreements.
22
// The .NET Foundation licenses this file to you under the MIT license.
33

4+
using Aspire.Cli.Builds;
45
using Aspire.Cli.Interaction;
56
using Semver;
67
using System.Diagnostics;
@@ -61,13 +62,14 @@ internal static class AppHostHelper
6162
return appHostInformationResult;
6263
}
6364

64-
internal static async Task<int> BuildAppHostAsync(IDotNetCliRunner runner, IInteractionService interactionService, FileInfo projectFile, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken)
65+
internal static async Task<int> BuildAppHostAsync(IAppHostBuilder builder, bool useCache, IInteractionService interactionService, FileInfo projectFile, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken)
6566
{
6667
return await interactionService.ShowStatusAsync(
6768
":hammer_and_wrench: Building app host...",
68-
() => runner.BuildAsync(
69-
projectFile,
70-
options,
71-
cancellationToken));
69+
() => builder.BuildAppHostAsync(
70+
projectFile,
71+
useCache,
72+
options,
73+
cancellationToken));
7274
}
7375
}

tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs

+9-1
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
using System.Text;
55
using Aspire.Cli.Backchannel;
6+
using Aspire.Cli.Builds;
67
using Aspire.Cli.Certificates;
78
using Aspire.Cli.Commands;
89
using Aspire.Cli.Interaction;
@@ -29,14 +30,15 @@ public static IServiceCollection CreateServiceCollection(ITestOutputHelper outpu
2930

3031
services.AddLogging();
3132

33+
services.AddSingleton(options.AppHostBuilderFactory);
3234
services.AddSingleton(options.AnsiConsoleFactory);
3335
services.AddSingleton(options.ProjectLocatorFactory);
3436
services.AddSingleton(options.InteractionServiceFactory);
3537
services.AddSingleton(options.CertificateServiceFactory);
3638
services.AddSingleton(options.NewCommandPrompterFactory);
3739
services.AddSingleton(options.AddCommandPrompterFactory);
3840
services.AddSingleton(options.PublishCommandPrompterFactory);
39-
services.AddTransient(options.DotNetCliRunnerFactory);
41+
services.AddSingleton(options.DotNetCliRunnerFactory);
4042
services.AddTransient(options.NuGetPackageCacheFactory);
4143
services.AddTransient<RootCommand>();
4244
services.AddTransient<NewCommand>();
@@ -51,6 +53,12 @@ public static IServiceCollection CreateServiceCollection(ITestOutputHelper outpu
5153

5254
internal sealed class CliServiceCollectionTestOptions(ITestOutputHelper outputHelper)
5355
{
56+
public Func<IServiceProvider, IAppHostBuilder> AppHostBuilderFactory { get; set; } = (IServiceProvider serviceProvider) => {
57+
var logger = serviceProvider.GetRequiredService<ILogger<AppHostBuilder>>();
58+
var runner = serviceProvider.GetRequiredService<IDotNetCliRunner>();
59+
return new AppHostBuilder(logger, runner);
60+
};
61+
5462
public Func<IServiceProvider, IAnsiConsole> AnsiConsoleFactory { get; set; } = (IServiceProvider serviceProvider) =>
5563
{
5664
AnsiConsoleSettings settings = new AnsiConsoleSettings()

0 commit comments

Comments
 (0)