Skip to content

Commit 6a3c091

Browse files
authored
Improve apphost discovery. (#8892)
* Add covering tests for existing behavior. * GitRootLocator. * WIP * WIP * WIP * WIP * Clean up error messages. * Write config file based on current working path.
1 parent 35d7c6d commit 6a3c091

File tree

13 files changed

+488
-37
lines changed

13 files changed

+488
-37
lines changed

src/Aspire.Cli/Aspire.Cli.csproj

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
<Project Sdk="Microsoft.NET.Sdk">
1+
<Project Sdk="Microsoft.NET.Sdk">
22

33
<PropertyGroup>
44
<OutputType>Exe</OutputType>
@@ -34,6 +34,7 @@
3434

3535
<ItemGroup>
3636
<Compile Include="$(RepoRoot)src\Shared\KnownConfigNames.cs" Link="KnownConfigNames.cs" />
37+
<Compile Include="$(SharedDir)PathNormalizer.cs" Link="Utils\PathNormalizer.cs" />
3738
</ItemGroup>
3839

3940
<ItemGroup>

src/Aspire.Cli/CliSettings.cs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
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.Text.Json.Serialization;
5+
6+
namespace Aspire.Cli;
7+
8+
internal class CliSettings
9+
{
10+
[JsonPropertyName("appHostPath")]
11+
public string? AppHostPath { get; set; }
12+
}

src/Aspire.Cli/Commands/AddCommand.cs

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -65,9 +65,12 @@ protected override async Task<int> ExecuteAsync(ParseResult parseResult, Cancell
6565
{
6666
var integrationName = parseResult.GetValue<string>("integration");
6767

68-
var passedAppHostProjectFile = parseResult.GetValue<FileInfo?>("--project");
69-
var effectiveAppHostProjectFile = _projectLocator.UseOrFindAppHostProjectFile(passedAppHostProjectFile);
70-
68+
var effectiveAppHostProjectFile = await _interactionService.ShowStatusAsync("Locating app host project...", async () =>
69+
{
70+
var passedAppHostProjectFile = parseResult.GetValue<FileInfo?>("--project");
71+
return await _projectLocator.UseOrFindAppHostProjectFileAsync(passedAppHostProjectFile, cancellationToken);
72+
});
73+
7174
if (effectiveAppHostProjectFile is null)
7275
{
7376
return ExitCodeConstants.FailedToFindProject;
@@ -146,9 +149,9 @@ protected override async Task<int> ExecuteAsync(ParseResult parseResult, Cancell
146149
_interactionService.DisplayError("The --project option specified a project that does not exist.");
147150
return ExitCodeConstants.FailedToFindProject;
148151
}
149-
catch (ProjectLocatorException ex) when (ex.Message.Contains("Nultiple project files"))
152+
catch (ProjectLocatorException ex) when (ex.Message.Contains("Multiple project files found."))
150153
{
151-
_interactionService.DisplayError("The --project option was not specified and multiple *.csproj files were detected.");
154+
_interactionService.DisplayError("The --project option was not specified and multiple app host project files were detected.");
152155
return ExitCodeConstants.FailedToFindProject;
153156
}
154157
catch (ProjectLocatorException ex) when (ex.Message.Contains("No project file"))

src/Aspire.Cli/Commands/PublishCommand.cs

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -73,9 +73,12 @@ protected override async Task<int> ExecuteAsync(ParseResult parseResult, Cancell
7373
{
7474
using var activity = _activitySource.StartActivity();
7575

76-
var passedAppHostProjectFile = parseResult.GetValue<FileInfo?>("--project");
77-
var effectiveAppHostProjectFile = _projectLocator.UseOrFindAppHostProjectFile(passedAppHostProjectFile);
78-
76+
var effectiveAppHostProjectFile = await _interactionService.ShowStatusAsync("Locating app host project...", async () =>
77+
{
78+
var passedAppHostProjectFile = parseResult.GetValue<FileInfo?>("--project");
79+
return await _projectLocator.UseOrFindAppHostProjectFileAsync(passedAppHostProjectFile, cancellationToken);
80+
});
81+
7982
if (effectiveAppHostProjectFile is null)
8083
{
8184
return ExitCodeConstants.FailedToFindProject;
@@ -273,9 +276,9 @@ protected override async Task<int> ExecuteAsync(ParseResult parseResult, Cancell
273276
_interactionService.DisplayError("The --project option specified a project that does not exist.");
274277
return ExitCodeConstants.FailedToFindProject;
275278
}
276-
catch (ProjectLocatorException ex) when (ex.Message.Contains("Nultiple project files"))
279+
catch (ProjectLocatorException ex) when (ex.Message.Contains("Multiple project files found."))
277280
{
278-
_interactionService.DisplayError("The --project option was not specified and multiple *.csproj files were detected.");
281+
_interactionService.DisplayError("The --project option was not specified and multiple app host project files were detected.");
279282
return ExitCodeConstants.FailedToFindProject;
280283
}
281284
catch (ProjectLocatorException ex) when (ex.Message.Contains("No project file"))

src/Aspire.Cli/Commands/RunCommand.cs

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -55,8 +55,11 @@ protected override async Task<int> ExecuteAsync(ParseResult parseResult, Cancell
5555
{
5656
using var activity = _activitySource.StartActivity();
5757

58-
var passedAppHostProjectFile = parseResult.GetValue<FileInfo?>("--project");
59-
var effectiveAppHostProjectFile = _projectLocator.UseOrFindAppHostProjectFile(passedAppHostProjectFile);
58+
var effectiveAppHostProjectFile = await _interactionService.ShowStatusAsync("Locating app host project...", async () =>
59+
{
60+
var passedAppHostProjectFile = parseResult.GetValue<FileInfo?>("--project");
61+
return await _projectLocator.UseOrFindAppHostProjectFileAsync(passedAppHostProjectFile, cancellationToken);
62+
});
6063

6164
if (effectiveAppHostProjectFile is null)
6265
{
@@ -223,7 +226,7 @@ await _ansiConsole.Live(table).StartAsync(async context =>
223226
}
224227
catch (ProjectLocatorException ex) when (ex.Message.Contains("Multiple project files"))
225228
{
226-
_interactionService.DisplayError("The --project option was not specified and multiple *.csproj files were detected.");
229+
_interactionService.DisplayError("The --project option was not specified and multiple app host project files were detected.");
227230
return ExitCodeConstants.FailedToFindProject;
228231
}
229232
catch (ProjectLocatorException ex) when (ex.Message.Contains("No project file"))
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
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.Text.Json.Serialization;
5+
6+
namespace Aspire.Cli;
7+
8+
[JsonSourceGenerationOptions(WriteIndented = true)]
9+
[JsonSerializable(typeof(CliSettings))]
10+
internal partial class JsonSourceGenerationContext : JsonSerializerContext
11+
{
12+
}

src/Aspire.Cli/Program.cs

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
using Microsoft.Extensions.Hosting;
1414
using Microsoft.Extensions.Logging;
1515
using Spectre.Console;
16+
using Microsoft.Extensions.Configuration;
1617

1718
#if DEBUG
1819
using OpenTelemetry;
@@ -28,9 +29,45 @@ public class Program
2829
{
2930
private static readonly ActivitySource s_activitySource = new ActivitySource(nameof(Program));
3031

32+
/// <summary>
33+
/// This method walks up the directory tree looking for the .aspire/settings.json files
34+
/// and then adds them to the host as a configuration source. This means that the settings
35+
/// architecture for the CLI will just be standard .NET configuraiton.
36+
/// </summary>
37+
private static void SetupAppHostOptions(HostApplicationBuilder builder)
38+
{
39+
var settingsFiles = new List<FileInfo>();
40+
var currentDirectory = new DirectoryInfo(Directory.GetCurrentDirectory());
41+
42+
while (true)
43+
{
44+
var settingsFilePath = Path.Combine(currentDirectory.FullName, ".aspire", "settings.json");
45+
46+
if (File.Exists(settingsFilePath))
47+
{
48+
var settingsFile = new FileInfo(settingsFilePath);
49+
settingsFiles.Add(settingsFile);
50+
}
51+
52+
if (currentDirectory.Parent is null)
53+
{
54+
break;
55+
}
56+
57+
currentDirectory = currentDirectory.Parent;
58+
}
59+
60+
settingsFiles.Reverse();
61+
foreach (var settingsFile in settingsFiles)
62+
{
63+
builder.Configuration.AddJsonFile(settingsFile.FullName);
64+
}
65+
}
66+
3167
private static IHost BuildApplication(string[] args)
3268
{
3369
var builder = Host.CreateApplicationBuilder();
70+
SetupAppHostOptions(builder);
3471

3572
builder.Logging.ClearProviders();
3673

@@ -114,7 +151,8 @@ private static IAnsiConsole BuildAnsiConsole(IServiceProvider serviceProvider)
114151
private static IProjectLocator BuildProjectLocator(IServiceProvider serviceProvider)
115152
{
116153
var logger = serviceProvider.GetRequiredService<ILogger<ProjectLocator>>();
117-
return new ProjectLocator(logger, Directory.GetCurrentDirectory());
154+
var runner = serviceProvider.GetRequiredService<IDotNetCliRunner>();
155+
return new ProjectLocator(logger, runner, new DirectoryInfo(Directory.GetCurrentDirectory()));
118156
}
119157

120158
public static async Task<int> Main(string[] args)
Lines changed: 116 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,92 @@
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 System.Diagnostics;
5+
using System.Text.Json;
46
using Microsoft.Extensions.Logging;
57

68
namespace Aspire.Cli.Projects;
79

810
internal interface IProjectLocator
911
{
10-
FileInfo? UseOrFindAppHostProjectFile(FileInfo? projectFile);
12+
Task<FileInfo?> UseOrFindAppHostProjectFileAsync(FileInfo? projectFile, CancellationToken cancellationToken = default);
1113
}
1214

13-
internal sealed class ProjectLocator(ILogger<ProjectLocator> logger, string currentDirectory) : IProjectLocator
15+
internal sealed class ProjectLocator(ILogger<ProjectLocator> logger, IDotNetCliRunner runner, DirectoryInfo currentDirectory) : IProjectLocator
1416
{
15-
public FileInfo? UseOrFindAppHostProjectFile(FileInfo? projectFile)
17+
private readonly ActivitySource _activitySource = new(nameof(ProjectLocator));
18+
19+
private async Task<List<FileInfo>> FindAppHostProjectFilesAsync(DirectoryInfo searchDirectory, CancellationToken cancellationToken)
20+
{
21+
using var activity = _activitySource.StartActivity();
22+
23+
var appHostProjects = new List<FileInfo>();
24+
25+
logger.LogDebug("Searching for project files in {SearchDirectory}", searchDirectory.FullName);
26+
var projectFiles = searchDirectory.GetFiles("*.csproj", SearchOption.AllDirectories);
27+
logger.LogDebug("Found {ProjectFileCount} project files in {SearchDirectory}", projectFiles.Length, searchDirectory.FullName);
28+
29+
foreach (var projectFile in projectFiles)
30+
{
31+
logger.LogDebug("Checking project file {ProjectFile}", projectFile.FullName);
32+
var information = await runner.GetAppHostInformationAsync(projectFile, cancellationToken);
33+
34+
if (information.ExitCode == 0 && information.IsAspireHost)
35+
{
36+
logger.LogDebug("Found AppHost project file {ProjectFile} in {SearchDirectory}", projectFile.FullName, searchDirectory.FullName);
37+
appHostProjects.Add(projectFile);
38+
}
39+
else
40+
{
41+
logger.LogTrace("Project file {ProjectFile} in {SearchDirectory} is not an Aspire host", projectFile.FullName, searchDirectory.FullName);
42+
}
43+
}
44+
45+
return appHostProjects;
46+
}
47+
48+
private async Task<FileInfo?> GetAppHostProjectFileFromSettingsAsync(CancellationToken cancellationToken)
49+
{
50+
var searchDirectory = currentDirectory;
51+
52+
while (true)
53+
{
54+
var settingsFile = new FileInfo(Path.Combine(searchDirectory.FullName, ".aspire", "settings.json"));
55+
56+
if (settingsFile.Exists)
57+
{
58+
using var stream = settingsFile.OpenRead();
59+
var json = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken);
60+
61+
if (json.RootElement.TryGetProperty("appHostPath", out var appHostPathProperty) && appHostPathProperty.GetString() is { } appHostPath )
62+
{
63+
64+
var qualifiedAppHostPath = Path.IsPathRooted(appHostPath) ? appHostPath : Path.Combine(settingsFile.Directory!.FullName, appHostPath);
65+
var appHostFile = new FileInfo(qualifiedAppHostPath);
66+
67+
if (appHostFile.Exists)
68+
{
69+
return appHostFile;
70+
}
71+
else
72+
{
73+
throw new ProjectLocatorException($"AppHost file was specified in '{settingsFile.FullName}' but it does not exist.");
74+
}
75+
}
76+
}
77+
78+
if (searchDirectory.Parent is not null)
79+
{
80+
searchDirectory = searchDirectory.Parent;
81+
}
82+
else
83+
{
84+
return null;
85+
}
86+
}
87+
}
88+
89+
public async Task<FileInfo?> UseOrFindAppHostProjectFileAsync(FileInfo? projectFile, CancellationToken cancellationToken = default)
1690
{
1791
logger.LogDebug("Finding project file in {CurrentDirectory}", currentDirectory);
1892

@@ -29,22 +103,52 @@ internal sealed class ProjectLocator(ILogger<ProjectLocator> logger, string curr
29103
return projectFile;
30104
}
31105

106+
projectFile = await GetAppHostProjectFileFromSettingsAsync(cancellationToken);
107+
108+
if (projectFile is not null)
109+
{
110+
return projectFile;
111+
}
112+
32113
logger.LogDebug("No project file specified, searching for *.csproj files in {CurrentDirectory}", currentDirectory);
33-
var projectFilePaths = Directory.GetFiles(currentDirectory, "*.csproj");
114+
var appHostProjects = await FindAppHostProjectFilesAsync(currentDirectory, cancellationToken);
34115

35-
logger.LogDebug("Found {ProjectFileCount} project files.", projectFilePaths.Length);
116+
logger.LogDebug("Found {ProjectFileCount} project files.", appHostProjects.Count);
36117

37-
return projectFilePaths switch {
38-
{ Length: 0 } => throw new ProjectLocatorException("No project file found."),
39-
{ Length: > 1 } => throw new ProjectLocatorException("Multiple project files found."),
40-
{ Length: 1 } => new FileInfo(projectFilePaths[0]),
118+
var selectedAppHost = appHostProjects.Count switch {
119+
0 => throw new ProjectLocatorException("No project file found."),
120+
> 1 => throw new ProjectLocatorException("Multiple project files found."),
121+
1 => appHostProjects[0],
122+
_ => throw new ProjectLocatorException("Unexpected number of project files found.")
41123
};
124+
125+
await CreateSettingsFileIfNotExistsAsync(selectedAppHost, cancellationToken);
126+
return selectedAppHost;
127+
}
128+
129+
private async Task CreateSettingsFileIfNotExistsAsync(FileInfo projectFile, CancellationToken cancellationToken)
130+
{
131+
var settingsFile = new FileInfo(Path.Combine(currentDirectory.FullName, ".aspire", "settings.json"));
132+
133+
if (!settingsFile.Exists)
134+
{
135+
if (!settingsFile.Directory!.Exists)
136+
{
137+
settingsFile.Directory.Create();
138+
}
139+
140+
var settings = new CliSettings
141+
{
142+
AppHostPath = Path.GetRelativePath(settingsFile.Directory.FullName, projectFile.FullName)
143+
};
144+
145+
using var stream = settingsFile.OpenWrite();
146+
await JsonSerializer.SerializeAsync(stream, settings, JsonSourceGenerationContext.Default.CliSettings, cancellationToken);
147+
}
42148
}
43149
}
44150

45151
internal class ProjectLocatorException : System.Exception
46152
{
47-
public ProjectLocatorException() { }
48153
public ProjectLocatorException(string message) : base(message) { }
49-
public ProjectLocatorException(string message, System.Exception inner) : base(message, inner) { }
50-
}
154+
}

tests/Aspire.Cli.Tests/Commands/RunCommandTests.cs

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
using Aspire.Cli.Backchannel;
55
using Aspire.Cli.Commands;
6+
using Aspire.Cli.Projects;
67
using Aspire.Cli.Tests.TestServices;
78
using Aspire.Cli.Tests.Utils;
89
using Aspire.Cli.Utils;
@@ -77,7 +78,7 @@ public async Task RunCommand_WhenProjectFileDoesNotExist_ReturnsNonZeroExitCode(
7778

7879
private sealed class ProjectFileDoesNotExistLocator : Aspire.Cli.Projects.IProjectLocator
7980
{
80-
public FileInfo? UseOrFindAppHostProjectFile(FileInfo? projectFile)
81+
public Task<FileInfo?> UseOrFindAppHostProjectFileAsync(FileInfo? projectFile, CancellationToken cancellationToken)
8182
{
8283
throw new Aspire.Cli.Projects.ProjectLocatorException("Project file does not exist.");
8384
}
@@ -107,17 +108,17 @@ public Task EnsureCertificatesTrustedAsync(IDotNetCliRunner runner, Cancellation
107108
}
108109
}
109110

110-
private sealed class NoProjectFileProjectLocator : Aspire.Cli.Projects.IProjectLocator
111+
private sealed class NoProjectFileProjectLocator : IProjectLocator
111112
{
112-
public FileInfo? UseOrFindAppHostProjectFile(FileInfo? projectFile)
113+
public Task<FileInfo?> UseOrFindAppHostProjectFileAsync(FileInfo? projectFile, CancellationToken cancellationToken)
113114
{
114115
throw new Aspire.Cli.Projects.ProjectLocatorException("No project file found.");
115116
}
116117
}
117118

118-
private sealed class MultipleProjectFilesProjectLocator : Aspire.Cli.Projects.IProjectLocator
119+
private sealed class MultipleProjectFilesProjectLocator : IProjectLocator
119120
{
120-
public FileInfo? UseOrFindAppHostProjectFile(FileInfo? projectFile)
121+
public Task<FileInfo?> UseOrFindAppHostProjectFileAsync(FileInfo? projectFile, CancellationToken cancellationToken)
121122
{
122123
throw new Aspire.Cli.Projects.ProjectLocatorException("Multiple project files found.");
123124
}

0 commit comments

Comments
 (0)