Skip to content

Basic implementation of build cache. #8754

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 88 additions & 0 deletions src/Aspire.Cli/Builds/AppHostBuilder.cs
Original file line number Diff line number Diff line change
@@ -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<int> BuildAppHostAsync(FileInfo projectFile, bool useCache, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken);
}

internal sealed class AppHostBuilder(ILogger<AppHostBuilder> logger, IDotNetCliRunner runner) : IAppHostBuilder
{
private readonly ActivitySource _activitySource = new ActivitySource(nameof(AppHostBuilder));
private readonly SHA256 _sha256 = SHA256.Create();

private async Task<string> GetBuildFingerprintAsync(FileInfo projectFile, CancellationToken cancellationToken)
{
using var activity = _activitySource.StartActivity();

_ = logger;

var msBuildResult = await runner.GetProjectItemsAndPropertiesAsync(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How do we feel about transitivity here? You might need the same finger print for the closure of project refs (that aren't aspire projects).

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

are environment variables and global properties already ingested at this point?

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);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit, Sha256.HashData is static these days

var hashString = Convert.ToHexString(hash);

return hashString;
}

private string GetAppHostStateBasePath(FileInfo projectFile)
{
var fullPath = projectFile.FullName;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lowercase this?

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<int> 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;
}
}
14 changes: 12 additions & 2 deletions src/Aspire.Cli/Commands/PublishCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<FileInfo?>("--project");
projectOption.Description = "The path to the Aspire app host project file.";
Expand All @@ -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<bool>("--no-cache", "-nc");
noCacheOption.Description = "Do not use cached build of the app host.";
Options.Add(noCacheOption);
}

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

var buildExitCode = await AppHostHelper.BuildAppHostAsync(_runner, _interactionService, effectiveAppHostProjectFile, buildOptions, cancellationToken);
var useCache = !parseResult.GetValue<bool>("--no-cache");

var buildExitCode = await AppHostHelper.BuildAppHostAsync(_appHostBuilder, useCache, _interactionService, effectiveAppHostProjectFile, buildOptions, cancellationToken);

if (buildExitCode != 0)
{
Expand Down
14 changes: 12 additions & 2 deletions src/Aspire.Cli/Commands/RunCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -23,21 +24,24 @@ 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);
ArgumentNullException.ThrowIfNull(interactionService);
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<FileInfo?>("--project");
projectOption.Description = "The path to the Aspire app host project file.";
Expand All @@ -46,6 +50,10 @@ public RunCommand(IDotNetCliRunner runner, IInteractionService interactionServic
var watchOption = new Option<bool>("--watch", "-w");
watchOption.Description = "Start project resources in watch mode.";
Options.Add(watchOption);

var noCacheOption = new Option<bool>("--no-cache", "-nc");
noCacheOption.Description = "Do not use cached build of the app host.";
Options.Add(noCacheOption);
}

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

if (!watch)
{
var useCache = !parseResult.GetValue<bool>("--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)
{
Expand Down
2 changes: 1 addition & 1 deletion src/Aspire.Cli/DotNetCliRunner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -331,7 +331,7 @@ public async Task<int> 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");
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍🏾


if (!Directory.Exists(dotnetCliPath))
{
Expand Down
4 changes: 3 additions & 1 deletion src/Aspire.Cli/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
using Microsoft.Extensions.Logging;
using Spectre.Console;
using Microsoft.Extensions.Configuration;
using Aspire.Cli.Builds;

#if DEBUG
using OpenTelemetry;
Expand Down Expand Up @@ -120,7 +121,8 @@ private static IHost BuildApplication(string[] args)
builder.Services.AddSingleton<IPublishCommandPrompter, PublishCommandPrompter>();
builder.Services.AddSingleton<IInteractionService, InteractionService>();
builder.Services.AddSingleton<ICertificateService, CertificateService>();
builder.Services.AddTransient<IDotNetCliRunner, DotNetCliRunner>();
builder.Services.AddSingleton<IAppHostBuilder, AppHostBuilder>();
builder.Services.AddSingleton<IDotNetCliRunner, DotNetCliRunner>();
builder.Services.AddTransient<IAppHostBackchannel, AppHostBackchannel>();
builder.Services.AddSingleton<CliRpcTarget>();
builder.Services.AddTransient<INuGetPackageCache, NuGetPackageCache>();
Expand Down
12 changes: 7 additions & 5 deletions src/Aspire.Cli/Utils/AppHostHelper.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -61,13 +62,14 @@ internal static class AppHostHelper
return appHostInformationResult;
}

internal static async Task<int> BuildAppHostAsync(IDotNetCliRunner runner, IInteractionService interactionService, FileInfo projectFile, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken)
internal static async Task<int> 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));
}
}
16 changes: 15 additions & 1 deletion tests/Aspire.Cli.Tests/TestServices/TestDotNetCliRunner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -50,11 +50,25 @@ public Task<int> 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)
Expand Down
10 changes: 9 additions & 1 deletion tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -29,14 +30,15 @@ public static IServiceCollection CreateServiceCollection(ITestOutputHelper outpu

services.AddLogging();

services.AddSingleton(options.AppHostBuilderFactory);
services.AddSingleton(options.AnsiConsoleFactory);
services.AddSingleton(options.ProjectLocatorFactory);
services.AddSingleton(options.InteractionServiceFactory);
services.AddSingleton(options.CertificateServiceFactory);
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<RootCommand>();
services.AddTransient<NewCommand>();
Expand All @@ -51,6 +53,12 @@ public static IServiceCollection CreateServiceCollection(ITestOutputHelper outpu

internal sealed class CliServiceCollectionTestOptions(ITestOutputHelper outputHelper)
{
public Func<IServiceProvider, IAppHostBuilder> AppHostBuilderFactory { get; set; } = (IServiceProvider serviceProvider) => {
var logger = serviceProvider.GetRequiredService<ILogger<AppHostBuilder>>();
var runner = serviceProvider.GetRequiredService<IDotNetCliRunner>();
return new AppHostBuilder(logger, runner);
};

public Func<IServiceProvider, IAnsiConsole> AnsiConsoleFactory { get; set; } = (IServiceProvider serviceProvider) =>
{
AnsiConsoleSettings settings = new AnsiConsoleSettings()
Expand Down