Skip to content

DRAFT: 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

Draft
wants to merge 3 commits into
base: main
Choose a base branch
from
Draft
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
87 changes: 87 additions & 0 deletions src/Aspire.Cli/Builds/AppHostBuilder.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
// 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, 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).

projectFile,
["ProjectReference", "PackageReference", "Compile"],
["OutputPath"],
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;
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, 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, cancellationToken);

await File.WriteAllTextAsync(buildFingerprintFile, currentFingerprint, cancellationToken);

return exitCode;
}
}
13 changes: 11 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 All @@ -18,17 +19,20 @@ internal sealed class PublishCommand : BaseCommand
private readonly IDotNetCliRunner _runner;
private readonly IInteractionService _interactionService;
private readonly IProjectLocator _projectLocator;
private readonly IAppHostBuilder _appHostBuilder;

public PublishCommand(IDotNetCliRunner runner, IInteractionService interactionService, IProjectLocator projectLocator)
public PublishCommand(IDotNetCliRunner runner, IInteractionService interactionService, IProjectLocator projectLocator, IAppHostBuilder appHostBuilder)
: base("publish", "Generates deployment artifacts for an Aspire app host project.")
{
ArgumentNullException.ThrowIfNull(runner);
ArgumentNullException.ThrowIfNull(interactionService);
ArgumentNullException.ThrowIfNull(projectLocator);
ArgumentNullException.ThrowIfNull(appHostBuilder);
Comment on lines 27 to +30
Copy link
Member

Choose a reason for hiding this comment

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

Ya know, this argument null checking isn't really necessary 😄. We not shipping a public API.


_runner = runner;
_interactionService = interactionService;
_projectLocator = projectLocator;
_appHostBuilder = appHostBuilder;

var projectOption = new Option<FileInfo?>("--project");
projectOption.Description = "The path to the Aspire app host project file.";
Expand All @@ -43,6 +47,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 @@ -75,7 +83,8 @@ protected override async Task<int> ExecuteAsync(ParseResult parseResult, Cancell
return ExitCodeConstants.FailedToDotnetRunAppHost;
}

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

if (buildExitCode != 0)
{
Expand Down
13 changes: 11 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 @@ -22,19 +23,22 @@ internal sealed class RunCommand : BaseCommand
private readonly IInteractionService _interactionService;
private readonly ICertificateService _certificateService;
private readonly IProjectLocator _projectLocator;
private readonly IAppHostBuilder _appHostBuilder;

public RunCommand(IDotNetCliRunner runner, IInteractionService interactionService, ICertificateService certificateService, IProjectLocator projectLocator)
public RunCommand(IDotNetCliRunner runner, IInteractionService interactionService, ICertificateService certificateService, IProjectLocator projectLocator, 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(appHostBuilder);

_runner = runner;
_interactionService = interactionService;
_certificateService = certificateService;
_projectLocator = projectLocator;
_appHostBuilder = appHostBuilder;

var projectOption = new Option<FileInfo?>("--project");
projectOption.Description = "The path to the Aspire app host project file.";
Expand All @@ -44,6 +48,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 @@ -90,7 +98,8 @@ protected override async Task<int> ExecuteAsync(ParseResult parseResult, Cancell

if (!watch)
{
var buildExitCode = await AppHostHelper.BuildAppHostAsync(_runner, _interactionService, effectiveAppHostProjectFile, cancellationToken);
var useCache = !parseResult.GetValue<bool>("--no-cache");
var buildExitCode = await AppHostHelper.BuildAppHostAsync(_appHostBuilder, useCache, _interactionService, effectiveAppHostProjectFile, 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 @@ -304,7 +304,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");

if (!Directory.Exists(dotnetCliPath))
{
Expand Down
2 changes: 2 additions & 0 deletions src/Aspire.Cli/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
using System.Diagnostics;
using System.Text;
using Aspire.Cli.Backchannel;
using Aspire.Cli.Builds;
using Aspire.Cli.Certificates;
using Aspire.Cli.Commands;
using Aspire.Cli.Interaction;
Expand Down Expand Up @@ -76,6 +77,7 @@ private static IHost BuildApplication(string[] args)

// Shared services.
builder.Services.AddSingleton(BuildProjectLocator);
builder.Services.AddSingleton<IAppHostBuilder, AppHostBuilder>();
builder.Services.AddSingleton<INewCommandPrompter, NewCommandPrompter>();
builder.Services.AddSingleton<IInteractionService, InteractionService>();
builder.Services.AddSingleton<ICertificateService, CertificateService>();
Expand Down
5 changes: 3 additions & 2 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 @@ -59,10 +60,10 @@ internal static class AppHostHelper
return appHostInformationResult;
}

internal static async Task<int> BuildAppHostAsync(IDotNetCliRunner runner, IInteractionService interactionService, FileInfo projectFile, CancellationToken cancellationToken)
internal static async Task<int> BuildAppHostAsync(IAppHostBuilder appHostBuilder, bool useCache, IInteractionService interactionService, FileInfo projectFile, CancellationToken cancellationToken)
{
return await interactionService.ShowStatusAsync(
":hammer_and_wrench: Building app host...",
() => runner.BuildAsync(projectFile, cancellationToken));
() => appHostBuilder.BuildAppHostAsync(projectFile, useCache, cancellationToken));
}
}
8 changes: 8 additions & 0 deletions tests/Aspire.Cli.Tests/Utils/CliTestHelper.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.Certificates;
using Aspire.Cli.Commands;
using Aspire.Cli.Interaction;
Expand All @@ -20,6 +21,7 @@ public static IServiceCollection CreateServiceCollection(Action<CliServiceCollec
var services = new ServiceCollection();
services.AddLogging();

services.AddSingleton(options.AppHostBuilderFactory);
services.AddSingleton(options.ProjectLocatorFactory);
services.AddSingleton(options.InteractiveServiceFactory);
services.AddSingleton(options.CertificateServiceFactory);
Expand All @@ -38,6 +40,12 @@ public static IServiceCollection CreateServiceCollection(Action<CliServiceCollec

internal sealed class CliServiceCollectionTestOptions
{
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, INewCommandPrompter> NewCommandPrompterFactory { get; set; } = (IServiceProvider serviceProvider) =>
{
var interactionService = serviceProvider.GetRequiredService<IInteractionService>();
Expand Down