diff --git a/eng/Signing.props b/eng/Signing.props index 1d50eda172e..236a68adcbf 100644 --- a/eng/Signing.props +++ b/eng/Signing.props @@ -26,6 +26,12 @@ + + + + + + diff --git a/src/Aspire.Cli/Backchannel/AppHostBackchannel.cs b/src/Aspire.Cli/Backchannel/AppHostBackchannel.cs index a61855bda5a..7558ea489b4 100644 --- a/src/Aspire.Cli/Backchannel/AppHostBackchannel.cs +++ b/src/Aspire.Cli/Backchannel/AppHostBackchannel.cs @@ -11,13 +11,13 @@ namespace Aspire.Cli.Backchannel; internal sealed class AppHostBackchannel(ILogger logger, CliRpcTarget target) { - private readonly ActivitySource _activitySource = new(nameof(Aspire.Cli.Backchannel.AppHostBackchannel), "1.0.0"); + private readonly ActivitySource _activitySource = new(nameof(AppHostBackchannel)); private readonly TaskCompletionSource _rpcTaskCompletionSource = new(); private Process? _process; public async Task PingAsync(long timestamp, CancellationToken cancellationToken) { - using var activity = _activitySource.StartActivity(nameof(PingAsync), ActivityKind.Client); + using var activity = _activitySource.StartActivity(); var rpc = await _rpcTaskCompletionSource.Task; @@ -37,7 +37,7 @@ public async Task RequestStopAsync(CancellationToken cancellationToken) // of the AppHost process. The AppHost process will then trigger the shutdown // which will allow the CLI to await the pending run. - using var activity = _activitySource.StartActivity(nameof(RequestStopAsync), ActivityKind.Client); + using var activity = _activitySource.StartActivity(); var rpc = await _rpcTaskCompletionSource.Task; @@ -51,7 +51,7 @@ await rpc.InvokeWithCancellationAsync( public async Task<(string BaseUrlWithLoginToken, string? CodespacesUrlWithLoginToken)> GetDashboardUrlsAsync(CancellationToken cancellationToken) { - using var activity = _activitySource.StartActivity(nameof(GetDashboardUrlsAsync), ActivityKind.Client); + using var activity = _activitySource.StartActivity(); var rpc = await _rpcTaskCompletionSource.Task; @@ -67,7 +67,7 @@ await rpc.InvokeWithCancellationAsync( public async IAsyncEnumerable<(string Resource, string Type, string State, string[] Endpoints)> GetResourceStatesAsync([EnumeratorCancellation]CancellationToken cancellationToken) { - using var activity = _activitySource.StartActivity(nameof(GetResourceStatesAsync), ActivityKind.Client); + using var activity = _activitySource.StartActivity(); var rpc = await _rpcTaskCompletionSource.Task; @@ -88,7 +88,7 @@ await rpc.InvokeWithCancellationAsync( public async Task ConnectAsync(Process process, string socketPath, CancellationToken cancellationToken) { - using var activity = _activitySource.StartActivity(nameof(ConnectAsync), ActivityKind.Client); + using var activity = _activitySource.StartActivity(); _process = process; @@ -111,7 +111,7 @@ public async Task ConnectAsync(Process process, string socketPath, CancellationT public async Task GetPublishersAsync(CancellationToken cancellationToken) { - using var activity = _activitySource.StartActivity(nameof(GetPublishersAsync), ActivityKind.Client); + using var activity = _activitySource.StartActivity(); var rpc = await _rpcTaskCompletionSource.Task.ConfigureAwait(false); @@ -127,7 +127,7 @@ public async Task GetPublishersAsync(CancellationToken cancellationTok public async IAsyncEnumerable<(string Id, string StatusText, bool IsComplete, bool IsError)> GetPublishingActivitiesAsync([EnumeratorCancellation]CancellationToken cancellationToken) { - using var activity = _activitySource.StartActivity(nameof(GetPublishingActivitiesAsync), ActivityKind.Client); + using var activity = _activitySource.StartActivity(); var rpc = await _rpcTaskCompletionSource.Task; diff --git a/src/Aspire.Cli/Commands/AddCommand.cs b/src/Aspire.Cli/Commands/AddCommand.cs index 23c4380db1c..be68a9c02b6 100644 --- a/src/Aspire.Cli/Commands/AddCommand.cs +++ b/src/Aspire.Cli/Commands/AddCommand.cs @@ -11,42 +11,48 @@ namespace Aspire.Cli.Commands; internal sealed class AddCommand : BaseCommand { - private readonly ActivitySource _activitySource = new ActivitySource("Aspire.Cli"); + private readonly ActivitySource _activitySource = new ActivitySource(nameof(AddCommand)); private readonly DotNetCliRunner _runner; private readonly INuGetPackageCache _nuGetPackageCache; - public AddCommand(DotNetCliRunner runner, INuGetPackageCache nuGetPackageCache) : base("add", "Add an integration or other resource to the Aspire project.") + public AddCommand(DotNetCliRunner runner, INuGetPackageCache nuGetPackageCache) + : base("add", "Add an integration to the Aspire project.") { ArgumentNullException.ThrowIfNull(runner, nameof(runner)); ArgumentNullException.ThrowIfNull(nuGetPackageCache, nameof(nuGetPackageCache)); _runner = runner; _nuGetPackageCache = nuGetPackageCache; - var resourceArgument = new Argument("resource"); - resourceArgument.Arity = ArgumentArity.ZeroOrOne; - Arguments.Add(resourceArgument); + var integrationArgument = new Argument("integration"); + integrationArgument.Description = "The name of the integration to add (e.g. redis, postgres)."; + integrationArgument.Arity = ArgumentArity.ZeroOrOne; + Arguments.Add(integrationArgument); var projectOption = new Option("--project"); + projectOption.Description = "The path to the project file to add the integration to."; projectOption.Validators.Add(ProjectFileHelper.ValidateProjectOption); Options.Add(projectOption); var versionOption = new Option("--version", "-v"); + versionOption.Description = "The version of the integration to add."; Options.Add(versionOption); var prereleaseOption = new Option("--prerelease"); + prereleaseOption.Description = "Include pre-release versions of the integration when searching."; Options.Add(prereleaseOption); var sourceOption = new Option("--source", "-s"); + sourceOption.Description = "The NuGet source to use for the integration."; Options.Add(sourceOption); } protected override async Task ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken) { - using var activity = _activitySource.StartActivity($"{nameof(ExecuteAsync)}", ActivityKind.Internal); + using var activity = _activitySource.StartActivity(); try { - var integrationName = parseResult.GetValue("resource"); + var integrationName = parseResult.GetValue("integration"); var passedAppHostProjectFile = parseResult.GetValue("--project"); var effectiveAppHostProjectFile = ProjectFileHelper.UseOrFindAppHostProjectFile(passedAppHostProjectFile); @@ -62,7 +68,7 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell var packages = await AnsiConsole.Status().StartAsync( "Searching for Aspire packages...", - context => _nuGetPackageCache.GetPackagesAsync(effectiveAppHostProjectFile, prerelease, source, cancellationToken) + context => _nuGetPackageCache.GetPackagesAsync(effectiveAppHostProjectFile.Directory!, prerelease, source, cancellationToken) ); var version = parseResult.GetValue("--version"); diff --git a/src/Aspire.Cli/Commands/NewCommand.cs b/src/Aspire.Cli/Commands/NewCommand.cs index 7cbc4f104e1..b8f174f2b23 100644 --- a/src/Aspire.Cli/Commands/NewCommand.cs +++ b/src/Aspire.Cli/Commands/NewCommand.cs @@ -2,45 +2,50 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.CommandLine; -using System.CommandLine.Parsing; using System.Diagnostics; using Aspire.Cli.Utils; +using Semver; using Spectre.Console; namespace Aspire.Cli.Commands; internal sealed class NewCommand : BaseCommand { - private readonly ActivitySource _activitySource = new ActivitySource("Aspire.Cli"); + private readonly ActivitySource _activitySource = new ActivitySource(nameof(NewCommand)); private readonly DotNetCliRunner _runner; + private readonly INuGetPackageCache _nuGetPackageCache; - public NewCommand(DotNetCliRunner runner) : base("new", "Create a new Aspire sample project.") + public NewCommand(DotNetCliRunner runner, INuGetPackageCache nuGetPackageCache) + : base("new", "Create a new Aspire sample project.") { ArgumentNullException.ThrowIfNull(runner, nameof(runner)); + ArgumentNullException.ThrowIfNull(nuGetPackageCache, nameof(nuGetPackageCache)); _runner = runner; + _nuGetPackageCache = nuGetPackageCache; var templateArgument = new Argument("template"); - templateArgument.Validators.Add(ValidateProjectTemplate); + templateArgument.Description = "The name of the project template to use (e.g. aspire-starter, aspire)."; templateArgument.Arity = ArgumentArity.ZeroOrOne; Arguments.Add(templateArgument); var nameOption = new Option("--name", "-n"); + nameOption.Description = "The name of the project to create."; Options.Add(nameOption); var outputOption = new Option("--output", "-o"); + outputOption.Description = "The output path for the project."; Options.Add(outputOption); - - var prereleaseOption = new Option("--prerelease"); - Options.Add(prereleaseOption); var sourceOption = new Option("--source", "-s"); + sourceOption.Description = "The NuGet source to use for the project templates."; Options.Add(sourceOption); var templateVersionOption = new Option("--version", "-v"); + templateVersionOption.Description = "The version of the project templates to use."; Options.Add(templateVersionOption); } - private static void ValidateProjectTemplate(ArgumentResult result) + private static async Task<(string TemplateName, string TemplateDescription, string? PathAppendage)> GetProjectTemplateAsync(ParseResult parseResult, CancellationToken cancellationToken) { // TODO: We need to integrate with the template engine to interrogate // the list of available templates. For now we will just hard-code @@ -49,55 +54,91 @@ private static void ValidateProjectTemplate(ArgumentResult result) // Once we integrate with template engine we will also be able to // interrogate the various options and add them. For now we will // keep it simple. - string[] validTemplates = [ - "aspire-starter", - "aspire", - "aspire-apphost", - "aspire-servicedefaults", - "aspire-mstest", - "aspire-nunit", - "aspire-xunit" + (string TemplateName, string TemplateDescription, string? PathAppendage)[] validTemplates = [ + ("aspire-starter", "Aspire Starter App", "src") , + ("aspire", "Aspire Empty App", "src"), + ("aspire-apphost", "Aspire App Host", null), + ("aspire-servicedefaults", "Aspire Service Defaults", null), + ("aspire-mstest", "Aspire Test Project (MSTest)", null), + ("aspire-nunit", "Aspire Test Project (NUnit)", null), + ("aspire-xunit", "Aspire Test Project (xUnit)", null) ]; - var value = result.GetValueOrDefault(); - - if (value is null) + if (parseResult.GetValue("template") is { } templateName && validTemplates.SingleOrDefault(t => t.TemplateName == templateName) is { } template) { - // This is OK, for now we will use the default - // template of aspire-starter, but we might - // be able to do more intelligent selection in the - // future based on what is already in the working directory. - return; + return template; } - - if (value is { } templateName && !validTemplates.Contains(templateName)) + else { - result.AddError($"The specified template '{templateName}' is not valid. Valid templates are [{string.Join(", ", validTemplates)}]."); - return; + return await PromptUtils.PromptForSelectionAsync( + "Select a project template:", + validTemplates, + t => $"{t.TemplateName} ({t.TemplateDescription})", + cancellationToken + ); } } - protected override async Task ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken) + private static async Task GetProjectNameAsync(ParseResult parseResult, CancellationToken cancellationToken) { - using var activity = _activitySource.StartActivity($"{nameof(ExecuteAsync)}", ActivityKind.Internal); + if (parseResult.GetValue("--name") is not { } name) + { + var defaultName = new DirectoryInfo(Environment.CurrentDirectory).Name; + name = await PromptUtils.PromptForStringAsync("Enter the project name:", + defaultValue: defaultName, + cancellationToken: cancellationToken); + } - var templateVersion = parseResult.GetValue("--version"); - var prerelease = parseResult.GetValue("--prerelease"); + return name; + } - if (templateVersion is not null && prerelease) + private static async Task GetOutputPathAsync(ParseResult parseResult, string? pathAppendage, CancellationToken cancellationToken) + { + if (parseResult.GetValue("--output") is not { } outputPath) { - AnsiConsole.MarkupLine("[red bold]:thumbs_down: The --version and --prerelease options are mutually exclusive.[/]"); - return ExitCodeConstants.FailedToCreateNewProject; + outputPath = await PromptUtils.PromptForStringAsync( + "Enter the output path:", + defaultValue: Path.Combine(Environment.CurrentDirectory, pathAppendage ?? string.Empty), + cancellationToken: cancellationToken + ); } - else if (prerelease) + + return Path.GetFullPath(outputPath); + } + + private static async Task GetProjectTemplatesVersionAsync(ParseResult parseResult, CancellationToken cancellationToken) + { + if (parseResult.GetValue("--version") is { } version) { - templateVersion = "*-*"; + return version; } - else if (templateVersion is null) + else { - templateVersion = VersionHelper.GetDefaultTemplateVersion(); + version = await PromptUtils.PromptForStringAsync( + "Project templates version:", + defaultValue: VersionHelper.GetDefaultTemplateVersion(), + validator: (string value) => { + if (SemVersion.TryParse(value, out var parsedVersion)) + { + return ValidationResult.Success(); + } + + return ValidationResult.Error("Invalid version format. Please enter a valid version."); + }, + cancellationToken); + + return version; } + } + protected override async Task ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken) + { + using var activity = _activitySource.StartActivity(); + + var template = await GetProjectTemplateAsync(parseResult, cancellationToken); + var name = await GetProjectNameAsync(parseResult, cancellationToken); + var outputPath = await GetOutputPathAsync(parseResult, template.PathAppendage, cancellationToken); + var version = await GetProjectTemplatesVersionAsync(parseResult, cancellationToken); var source = parseResult.GetValue("--source"); var templateInstallResult = await AnsiConsole.Status() @@ -106,7 +147,7 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell .StartAsync( ":ice: Getting latest templates...", async context => { - return await _runner.InstallTemplateAsync("Aspire.ProjectTemplates", templateVersion!, source, true, cancellationToken); + return await _runner.InstallTemplateAsync("Aspire.ProjectTemplates", version, source, true, cancellationToken); }); if (templateInstallResult.ExitCode != 0) @@ -117,23 +158,6 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell AnsiConsole.MarkupLine($":package: Using project templates version: {templateInstallResult.TemplateVersion}"); - var templateName = parseResult.GetValue("template") ?? "aspire-starter"; - - if (parseResult.GetValue("--output") is not { } outputPath) - { - outputPath = Environment.CurrentDirectory; - } - else - { - outputPath = Path.GetFullPath(outputPath); - } - - if (parseResult.GetValue("--name") is not { } name) - { - var outputPathDirectoryInfo = new DirectoryInfo(outputPath); - name = outputPathDirectoryInfo.Name; - } - int newProjectExitCode = await AnsiConsole.Status() .Spinner(Spinner.Known.Dots3) .SpinnerStyle(Style.Parse("purple")) @@ -141,11 +165,11 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell ":rocket: Creating new Aspire project...", async context => { return await _runner.NewProjectAsync( - templateName, - name, - outputPath, - cancellationToken); - }); + template.TemplateName, + name, + outputPath, + cancellationToken); + }); if (newProjectExitCode != 0) { diff --git a/src/Aspire.Cli/Commands/PublishCommand.cs b/src/Aspire.Cli/Commands/PublishCommand.cs index 496a282bf2f..df9c19d8d97 100644 --- a/src/Aspire.Cli/Commands/PublishCommand.cs +++ b/src/Aspire.Cli/Commands/PublishCommand.cs @@ -12,29 +12,33 @@ namespace Aspire.Cli.Commands; internal sealed class PublishCommand : BaseCommand { - private readonly ActivitySource _activitySource = new ActivitySource("Aspire.Cli"); + private readonly ActivitySource _activitySource = new ActivitySource(nameof(PublishCommand)); private readonly DotNetCliRunner _runner; - public PublishCommand(DotNetCliRunner runner) : base("publish", "Generates deployment artifacts for an Aspire app host project.") + public PublishCommand(DotNetCliRunner runner) + : base("publish", "Generates deployment artifacts for an Aspire app host project.") { ArgumentNullException.ThrowIfNull(runner, nameof(runner)); _runner = runner; var projectOption = new Option("--project"); + projectOption.Description = "The path to the Aspire app host project file."; projectOption.Validators.Add(ProjectFileHelper.ValidateProjectOption); Options.Add(projectOption); var publisherOption = new Option("--publisher", "-p"); + publisherOption.Description = "The name of the publisher to use."; Options.Add(publisherOption); var outputPath = new Option("--output-path", "-o"); + outputPath.Description = "The output path for the generated artifacts."; outputPath.DefaultValueFactory = (result) => Path.Combine(Environment.CurrentDirectory); Options.Add(outputPath); } protected override async Task ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken) { - using var activity = _activitySource.StartActivity($"{nameof(ExecuteAsync)}", ActivityKind.Internal); + using var activity = _activitySource.StartActivity(); var passedAppHostProjectFile = parseResult.GetValue("--project"); var effectiveAppHostProjectFile = ProjectFileHelper.UseOrFindAppHostProjectFile(passedAppHostProjectFile); diff --git a/src/Aspire.Cli/Commands/RootCommand.cs b/src/Aspire.Cli/Commands/RootCommand.cs index a2f7cd1db05..22d4ad6bbdf 100644 --- a/src/Aspire.Cli/Commands/RootCommand.cs +++ b/src/Aspire.Cli/Commands/RootCommand.cs @@ -14,13 +14,16 @@ namespace Aspire.Cli.Commands; internal sealed class RootCommand : BaseRootCommand { - public RootCommand(NewCommand newCommand, RunCommand runCommand, AddCommand addCommand, PublishCommand publishCommand) : base("Aspire CLI") + public RootCommand(NewCommand newCommand, RunCommand runCommand, AddCommand addCommand, PublishCommand publishCommand) + : base("The Aspire CLI can be used to create, run, and publish Aspire-based applications.") { var debugOption = new Option("--debug", "-d"); + debugOption.Description = "Enable debug logging to the console."; debugOption.Recursive = true; Options.Add(debugOption); var waitForDebuggerOption = new Option("--wait-for-debugger", "-w"); + waitForDebuggerOption.Description = "Wait for a debugger to attach before executing the command."; waitForDebuggerOption.Recursive = true; waitForDebuggerOption.DefaultValueFactory = (result) => false; diff --git a/src/Aspire.Cli/Commands/RunCommand.cs b/src/Aspire.Cli/Commands/RunCommand.cs index 566667fd8d1..bb992a8fee7 100644 --- a/src/Aspire.Cli/Commands/RunCommand.cs +++ b/src/Aspire.Cli/Commands/RunCommand.cs @@ -14,26 +14,29 @@ namespace Aspire.Cli.Commands; internal sealed class RunCommand : BaseCommand { - private readonly ActivitySource _activitySource = new ActivitySource("Aspire.Cli"); + private readonly ActivitySource _activitySource = new ActivitySource(nameof(RunCommand)); private readonly DotNetCliRunner _runner; - public RunCommand(DotNetCliRunner runner) : base("run", "Run an Aspire app host in development mode.") + public RunCommand(DotNetCliRunner runner) + : base("run", "Run an Aspire app host in development mode.") { ArgumentNullException.ThrowIfNull(runner, nameof(runner)); _runner = runner; var projectOption = new Option("--project"); + projectOption.Description = "The path to the Aspire app host project file."; projectOption.Validators.Add(ProjectFileHelper.ValidateProjectOption); Options.Add(projectOption); var watchOption = new Option("--watch", "-w"); + watchOption.Description = "Start project resources in watch mode."; Options.Add(watchOption); } protected override async Task ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken) { - using var activity = _activitySource.StartActivity($"{nameof(ExecuteAsync)}", ActivityKind.Internal); + using var activity = _activitySource.StartActivity(); var passedAppHostProjectFile = parseResult.GetValue("--project"); var effectiveAppHostProjectFile = ProjectFileHelper.UseOrFindAppHostProjectFile(passedAppHostProjectFile); diff --git a/src/Aspire.Cli/DotNetCliRunner.cs b/src/Aspire.Cli/DotNetCliRunner.cs index ae8188cd700..40eafffa202 100644 --- a/src/Aspire.Cli/DotNetCliRunner.cs +++ b/src/Aspire.Cli/DotNetCliRunner.cs @@ -15,13 +15,13 @@ namespace Aspire.Cli; internal sealed class DotNetCliRunner(ILogger logger, IServiceProvider serviceProvider) { - private readonly ActivitySource _activitySource = new ActivitySource(nameof(Aspire.Cli.DotNetCliRunner)); + private readonly ActivitySource _activitySource = new ActivitySource(nameof(DotNetCliRunner)); internal Func GetCurrentProcessId { get; set; } = () => Environment.ProcessId; public async Task<(int ExitCode, bool IsAspireHost, string? AspireHostingSdkVersion)> GetAppHostInformationAsync(FileInfo projectFile, CancellationToken cancellationToken) { - using var activity = _activitySource.StartActivity(nameof(GetAppHostInformationAsync), ActivityKind.Client); + using var activity = _activitySource.StartActivity(); string[] cliArgs = ["msbuild", "-getproperty:IsAspireHost,AspireHostingSDKVersion"]; @@ -79,7 +79,7 @@ internal sealed class DotNetCliRunner(ILogger logger, IServiceP public async Task RunAsync(FileInfo projectFile, bool watch, bool noBuild, string[] args, IDictionary? env, TaskCompletionSource? backchannelCompletionSource, CancellationToken cancellationToken) { - using var activity = _activitySource.StartActivity(nameof(RunAsync), ActivityKind.Client); + using var activity = _activitySource.StartActivity(); if (watch && noBuild) { @@ -100,7 +100,7 @@ public async Task RunAsync(FileInfo projectFile, bool watch, bool noBuild, public async Task CheckHttpCertificateAsync(CancellationToken cancellationToken) { - using var activity = _activitySource.StartActivity(nameof(CheckHttpCertificateAsync), ActivityKind.Client); + using var activity = _activitySource.StartActivity(); string[] cliArgs = ["dev-certs", "https", "--check", "--trust"]; return await ExecuteAsync( @@ -114,7 +114,7 @@ public async Task CheckHttpCertificateAsync(CancellationToken cancellationT public async Task TrustHttpCertificateAsync(CancellationToken cancellationToken) { - using var activity = _activitySource.StartActivity(nameof(TrustHttpCertificateAsync), ActivityKind.Client); + using var activity = _activitySource.StartActivity(); string[] cliArgs = ["dev-certs", "https", "--trust"]; return await ExecuteAsync( @@ -230,7 +230,7 @@ private static bool TryParsePackageVersionFromStdout(string stdout, [NotNullWhen public async Task NewProjectAsync(string templateName, string name, string outputPath, CancellationToken cancellationToken) { - using var activity = _activitySource.StartActivity(nameof(NewProjectAsync), ActivityKind.Client); + using var activity = _activitySource.StartActivity(); string[] cliArgs = ["new", templateName, "--name", name, "--output", outputPath]; return await ExecuteAsync( @@ -259,7 +259,7 @@ internal static string GetBackchannelSocketPath() public async Task ExecuteAsync(string[] args, IDictionary? env, DirectoryInfo workingDirectory, TaskCompletionSource? backchannelCompletionSource, Action? streamsCallback, CancellationToken cancellationToken) { - using var activity = _activitySource.StartActivity(nameof(ExecuteAsync), ActivityKind.Client); + using var activity = _activitySource.StartActivity(); var startInfo = new ProcessStartInfo("dotnet") { @@ -379,7 +379,7 @@ async Task ForwardStreamToLoggerAsync(StreamReader reader, string identifier, Pr private async Task StartBackchannelAsync(Process process, string socketPath, TaskCompletionSource backchannelCompletionSource, CancellationToken cancellationToken) { - using var activity = _activitySource.StartActivity(nameof(StartBackchannelAsync), ActivityKind.Client); + using var activity = _activitySource.StartActivity(); using var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(50)); @@ -431,7 +431,7 @@ private async Task StartBackchannelAsync(Process process, string socketPath, Tas public async Task BuildAsync(FileInfo projectFilePath, CancellationToken cancellationToken) { - using var activity = _activitySource.StartActivity(nameof(BuildAsync), ActivityKind.Client); + using var activity = _activitySource.StartActivity(); string[] cliArgs = ["build", projectFilePath.FullName]; return await ExecuteAsync( @@ -444,7 +444,7 @@ public async Task BuildAsync(FileInfo projectFilePath, CancellationToken ca } public async Task AddPackageAsync(FileInfo projectFilePath, string packageName, string packageVersion, CancellationToken cancellationToken) { - using var activity = _activitySource.StartActivity(nameof(AddPackageAsync), ActivityKind.Client); + using var activity = _activitySource.StartActivity(); string[] cliArgs = [ "add", @@ -477,9 +477,9 @@ public async Task AddPackageAsync(FileInfo projectFilePath, string packageN return result; } - public async Task<(int ExitCode, NuGetPackage[]? Packages)> SearchPackagesAsync(FileInfo projectFilePath, string query, bool prerelease, int take, int skip, string? nugetSource, CancellationToken cancellationToken) + public async Task<(int ExitCode, NuGetPackage[]? Packages)> SearchPackagesAsync(DirectoryInfo workingDirectory, string query, bool prerelease, int take, int skip, string? nugetSource, CancellationToken cancellationToken) { - using var activity = _activitySource.StartActivity(nameof(SearchPackagesAsync), ActivityKind.Client); + using var activity = _activitySource.StartActivity(); List cliArgs = [ "package", @@ -510,7 +510,7 @@ public async Task AddPackageAsync(FileInfo projectFilePath, string packageN var result = await ExecuteAsync( args: cliArgs.ToArray(), env: null, - workingDirectory: projectFilePath.Directory!, + workingDirectory: workingDirectory!, backchannelCompletionSource: null, streamsCallback: (_, output, _) => { // We need to read the output of the streams @@ -550,6 +550,12 @@ public async Task AddPackageAsync(FileInfo projectFilePath, string packageN foreach (var packageResult in sourcePackagesArray.EnumerateArray()) { var id = packageResult.GetProperty("id").GetString(); + + // var version = prerelease switch { + // true => packageResult.GetProperty("version").GetString(), + // false => packageResult.GetProperty("latestVersion").GetString() + // }; + var version = packageResult.GetProperty("latestVersion").GetString(); foundPackages.Add(new NuGetPackage diff --git a/src/Aspire.Cli/NuGetPackageCache.cs b/src/Aspire.Cli/NuGetPackageCache.cs index ef90821c873..402c99a9f92 100644 --- a/src/Aspire.Cli/NuGetPackageCache.cs +++ b/src/Aspire.Cli/NuGetPackageCache.cs @@ -8,18 +8,18 @@ namespace Aspire.Cli; internal interface INuGetPackageCache { - Task> GetPackagesAsync(FileInfo projectFile, bool prerelease, string? source, CancellationToken cancellationToken); + Task> GetPackagesAsync(DirectoryInfo workingDirectory, bool prerelease, string? source, CancellationToken cancellationToken); } internal sealed class NuGetPackageCache(ILogger logger, DotNetCliRunner cliRunner) : INuGetPackageCache { - private readonly ActivitySource _activitySource = new(nameof(Aspire.Cli.NuGetPackageCache), "1.0.0"); + private readonly ActivitySource _activitySource = new(nameof(NuGetPackageCache)); private const int SearchPageSize = 100; - public async Task> GetPackagesAsync(FileInfo projectFile, bool prerelease, string? source, CancellationToken cancellationToken) + public async Task> GetPackagesAsync(DirectoryInfo workingDirectory, bool prerelease, string? source, CancellationToken cancellationToken) { - using var activity = _activitySource.StartActivity(nameof(GetPackagesAsync), ActivityKind.Client); + using var activity = _activitySource.StartActivity(); logger.LogDebug("Getting integrations from NuGet"); @@ -31,7 +31,7 @@ public async Task> GetPackagesAsync(FileInfo projectFi { // This search should pick up Aspire.Hosting.* and CommunityToolkit.Aspire.Hosting.* var result = await cliRunner.SearchPackagesAsync( - projectFile, + workingDirectory, "Aspire.Hosting", prerelease, SearchPageSize, diff --git a/src/Aspire.Cli/Program.cs b/src/Aspire.Cli/Program.cs index 37ec3f33636..9d86c0ee70a 100644 --- a/src/Aspire.Cli/Program.cs +++ b/src/Aspire.Cli/Program.cs @@ -10,13 +10,15 @@ using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using OpenTelemetry; +using OpenTelemetry.Resources; +using OpenTelemetry.Trace; using RootCommand = Aspire.Cli.Commands.RootCommand; namespace Aspire.Cli; public class Program { - private static readonly ActivitySource s_activitySource = new ActivitySource(nameof(Aspire.Cli.Program)); + private static readonly ActivitySource s_activitySource = new ActivitySource(nameof(Program)); private static IHost BuildApplication(string[] args) { @@ -30,14 +32,22 @@ private static IHost BuildApplication(string[] args) logging.IncludeScopes = true; }); - var otelBuilder = builder.Services.AddOpenTelemetry() - .WithTracing(tracing => { - tracing.AddSource( - nameof(Aspire.Cli.NuGetPackageCache), - nameof(Aspire.Cli.Backchannel.AppHostBackchannel), - nameof(Aspire.Cli.DotNetCliRunner), - nameof(Aspire.Cli.Program)); - }); + var otelBuilder = builder.Services + .AddOpenTelemetry() + .WithTracing(tracing => { + tracing.AddSource( + nameof(NuGetPackageCache), + nameof(AppHostBackchannel), + nameof(DotNetCliRunner), + nameof(Program), + nameof(NewCommand), + nameof(RunCommand), + nameof(AddCommand), + nameof(PublishCommand) + ); + + tracing.SetResourceBuilder(ResourceBuilder.CreateDefault().AddService("aspire-cli")); + }); if (builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"] is {}) { @@ -84,7 +94,7 @@ public static async Task Main(string[] args) var config = new CommandLineConfiguration(rootCommand); config.EnableDefaultExceptionHandler = true; - using var activity = s_activitySource.StartActivity(nameof(Main), ActivityKind.Internal); + using var activity = s_activitySource.StartActivity(); var exitCode = await config.InvokeAsync(args); await app.StopAsync().ConfigureAwait(false); diff --git a/src/Aspire.Cli/Utils/PromptUtils.cs b/src/Aspire.Cli/Utils/PromptUtils.cs new file mode 100644 index 00000000000..4b00b885e1d --- /dev/null +++ b/src/Aspire.Cli/Utils/PromptUtils.cs @@ -0,0 +1,45 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Spectre.Console; + +namespace Aspire.Cli.Utils; + +internal static class PromptUtils +{ + public static async Task PromptForStringAsync(string promptText, string? defaultValue = null, Func? validator = null, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(promptText, nameof(promptText)); + var prompt = new TextPrompt(promptText); + + if (defaultValue is not null) + { + prompt.DefaultValue(defaultValue); + prompt.ShowDefaultValue(); + } + + if (validator is not null) + { + prompt.Validate(validator); + } + + return await AnsiConsole.PromptAsync(prompt, cancellationToken); + } + + public static async Task PromptForSelectionAsync(string promptText, IEnumerable choices, Func choiceFormatter, CancellationToken cancellationToken = default) where T: notnull + { + ArgumentNullException.ThrowIfNull(promptText, nameof(promptText)); + ArgumentNullException.ThrowIfNull(choices, nameof(choices)); + ArgumentNullException.ThrowIfNull(choiceFormatter, nameof(choiceFormatter)); + + var prompt = new SelectionPrompt() + .Title(promptText) + .UseConverter(choiceFormatter) + .AddChoices(choices) + .PageSize(10) + .EnableSearch() + .HighlightStyle(Style.Parse("darkmagenta")); + + return await AnsiConsole.PromptAsync(prompt, cancellationToken); + } +} \ No newline at end of file diff --git a/src/Aspire.Hosting.Azure.AppContainers/AzureContainerAppEnvironmentResource.cs b/src/Aspire.Hosting.Azure.AppContainers/AzureContainerAppEnvironmentResource.cs index ce17e9813b3..276cce3e9f6 100644 --- a/src/Aspire.Hosting.Azure.AppContainers/AzureContainerAppEnvironmentResource.cs +++ b/src/Aspire.Hosting.Azure.AppContainers/AzureContainerAppEnvironmentResource.cs @@ -16,42 +16,42 @@ public class AzureContainerAppEnvironmentResource(string name, Action /// Gets the unique identifier of the Container App Environment. /// - public BicepOutputReference ContainerAppEnvironmentId => new("AZURE_CONTAINER_APPS_ENVIRONMENT_ID", this); + private BicepOutputReference ContainerAppEnvironmentId => new("AZURE_CONTAINER_APPS_ENVIRONMENT_ID", this); /// /// Gets the default domain associated with the Container App Environment. /// - public BicepOutputReference ContainerAppDomain => new("AZURE_CONTAINER_APPS_ENVIRONMENT_DEFAULT_DOMAIN", this); + private BicepOutputReference ContainerAppDomain => new("AZURE_CONTAINER_APPS_ENVIRONMENT_DEFAULT_DOMAIN", this); /// /// Gets the URL endpoint of the associated Azure Container Registry. /// - public BicepOutputReference ContainerRegistryUrl => new("AZURE_CONTAINER_REGISTRY_ENDPOINT", this); + private BicepOutputReference ContainerRegistryUrl => new("AZURE_CONTAINER_REGISTRY_ENDPOINT", this); /// /// Gets the managed identity ID associated with the Azure Container Registry. /// - public BicepOutputReference ContainerRegistryManagedIdentityId => new("AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID", this); + private BicepOutputReference ContainerRegistryManagedIdentityId => new("AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID", this); /// /// Gets the unique identifier of the Log Analytics workspace. /// - public BicepOutputReference LogAnalyticsWorkspaceId => new("AZURE_LOG_ANALYTICS_WORKSPACE_ID", this); + private BicepOutputReference LogAnalyticsWorkspaceId => new("AZURE_LOG_ANALYTICS_WORKSPACE_ID", this); /// /// Gets the principal name of the managed identity. /// - public BicepOutputReference PrincipalName => new("MANAGED_IDENTITY_NAME", this); + private BicepOutputReference PrincipalName => new("MANAGED_IDENTITY_NAME", this); /// /// Gets the principal ID of the managed identity. /// - public BicepOutputReference PrincipalId => new("MANAGED_IDENTITY_PRINCIPAL_ID", this); + private BicepOutputReference PrincipalId => new("MANAGED_IDENTITY_PRINCIPAL_ID", this); /// /// Gets the name of the Container App Environment. /// - public BicepOutputReference ContainerAppEnvironmentName => new("AZURE_CONTAINER_APPS_ENVIRONMENT_NAME", this); + private BicepOutputReference ContainerAppEnvironmentName => new("AZURE_CONTAINER_APPS_ENVIRONMENT_NAME", this); internal Dictionary VolumeNames { get; } = []; diff --git a/src/Aspire.Hosting.Azure.AppContainers/AzureContainerAppsInfrastructure.cs b/src/Aspire.Hosting.Azure.AppContainers/AzureContainerAppsInfrastructure.cs index 2f0a1a3ccbe..cfce5b42e64 100644 --- a/src/Aspire.Hosting.Azure.AppContainers/AzureContainerAppsInfrastructure.cs +++ b/src/Aspire.Hosting.Azure.AppContainers/AzureContainerAppsInfrastructure.cs @@ -714,7 +714,7 @@ BicepValue GetHostValue(string? prefix = null, string? suffix = null) return (AllocateParameter(secretOutputReference, secretType: SecretType.KeyVault), SecretType.KeyVault); } - if (value is IKeyVaultSecretReference vaultSecretReference) + if (value is IAzureKeyVaultSecretReference vaultSecretReference) { if (parent is null) { @@ -797,7 +797,7 @@ private BicepValue AllocateKeyVaultSecretUriReference(BicepSecretOutputR return secret.Properties.SecretUri; } - private BicepValue AllocateKeyVaultSecretUriReference(IKeyVaultSecretReference secretOutputReference) + private BicepValue AllocateKeyVaultSecretUriReference(IAzureKeyVaultSecretReference secretOutputReference) { if (!KeyVaultRefs.TryGetValue(secretOutputReference.Resource.Name, out var kv)) { diff --git a/src/Aspire.Hosting.Azure.CosmosDB/AzureCosmosDBExtensions.cs b/src/Aspire.Hosting.Azure.CosmosDB/AzureCosmosDBExtensions.cs index d0e027c8277..8bdeb655259 100644 --- a/src/Aspire.Hosting.Azure.CosmosDB/AzureCosmosDBExtensions.cs +++ b/src/Aspire.Hosting.Azure.CosmosDB/AzureCosmosDBExtensions.cs @@ -342,6 +342,20 @@ public static IResourceBuilder WithAccessKeyAuthenticatio var kv = builder.ApplicationBuilder.AddAzureKeyVault($"{builder.Resource.Name}-kv") .WithParentRelationship(builder.Resource); + // remove the KeyVault from the model if the emulator is used during run mode. + // need to do this later in case builder becomes an emulator after this method is called. + if (builder.ApplicationBuilder.ExecutionContext.IsRunMode) + { + builder.ApplicationBuilder.Eventing.Subscribe((data, _) => + { + if (builder.Resource.IsEmulator) + { + data.Model.Resources.Remove(kv.Resource); + } + return Task.CompletedTask; + }); + } + return builder.WithAccessKeyAuthentication(kv); } @@ -351,7 +365,7 @@ public static IResourceBuilder WithAccessKeyAuthenticatio /// The Azure Cosmos DB resource builder. /// The Azure Key Vault resource builder where the connection string used to connect to this AzureCosmosDBResource will be stored. /// A reference to the builder. - public static IResourceBuilder WithAccessKeyAuthentication(this IResourceBuilder builder, IResourceBuilder keyVaultBuilder) + public static IResourceBuilder WithAccessKeyAuthentication(this IResourceBuilder builder, IResourceBuilder keyVaultBuilder) { ArgumentNullException.ThrowIfNull(builder); diff --git a/src/Aspire.Hosting.Azure.CosmosDB/AzureCosmosDBResource.cs b/src/Aspire.Hosting.Azure.CosmosDB/AzureCosmosDBResource.cs index eb9cb02fc62..f21cccc6839 100644 --- a/src/Aspire.Hosting.Azure.CosmosDB/AzureCosmosDBResource.cs +++ b/src/Aspire.Hosting.Azure.CosmosDB/AzureCosmosDBResource.cs @@ -41,7 +41,7 @@ public class AzureCosmosDBResource(string name, Action - internal IKeyVaultSecretReference? ConnectionStringSecretOutput { get; set; } + internal IAzureKeyVaultSecretReference? ConnectionStringSecretOutput { get; set; } private BicepOutputReference NameOutputReference => new("name", this); @@ -134,7 +134,7 @@ internal ReferenceExpression GetChildConnectionString(string childResourceName, var builder = new ReferenceExpressionBuilder(); - if (UseAccessKeyAuthentication) + if (UseAccessKeyAuthentication && !IsEmulator) { builder.AppendFormatted(ConnectionStringSecretOutput.Resource.GetSecretReference(GetKeyValueSecretName(childResourceName))); } diff --git a/src/Aspire.Hosting.Azure.KeyVault/AzureKeyVaultResource.cs b/src/Aspire.Hosting.Azure.KeyVault/AzureKeyVaultResource.cs index bfae31c431e..bd6a3580cb8 100644 --- a/src/Aspire.Hosting.Azure.KeyVault/AzureKeyVaultResource.cs +++ b/src/Aspire.Hosting.Azure.KeyVault/AzureKeyVaultResource.cs @@ -13,7 +13,7 @@ namespace Aspire.Hosting.Azure; /// The name of the resource. /// Callback to configure the Azure resources. public class AzureKeyVaultResource(string name, Action configureInfrastructure) - : AzureProvisioningResource(name, configureInfrastructure), IResourceWithConnectionString, IKeyVaultResource + : AzureProvisioningResource(name, configureInfrastructure), IResourceWithConnectionString, IAzureKeyVaultResource { /// /// Gets the "vaultUri" output reference for the Azure Key Vault resource. @@ -31,12 +31,12 @@ public class AzureKeyVaultResource(string name, Action ReferenceExpression.Create($"{VaultUri}"); - BicepOutputReference IKeyVaultResource.VaultUriOutputReference => VaultUri; + BicepOutputReference IAzureKeyVaultResource.VaultUriOutputReference => VaultUri; // In run mode, this is set to the secret client used to access the Azure Key Vault. - internal Func>? SecretResolver { get; set; } + internal Func>? SecretResolver { get; set; } - Func>? IKeyVaultResource.SecretResolver + Func>? IAzureKeyVaultResource.SecretResolver { get => SecretResolver; set => SecretResolver = value; @@ -48,7 +48,7 @@ public class AzureKeyVaultResource(string name, Action /// /// - public IKeyVaultSecretReference GetSecretReference(string secretName) + public IAzureKeyVaultSecretReference GetSecretReference(string secretName) { ArgumentException.ThrowIfNullOrEmpty(secretName, nameof(secretName)); diff --git a/src/Aspire.Hosting.Azure.KeyVault/AzureKeyVaultSecretReference.cs b/src/Aspire.Hosting.Azure.KeyVault/AzureKeyVaultSecretReference.cs index 3f078529b32..b4cfb718996 100644 --- a/src/Aspire.Hosting.Azure.KeyVault/AzureKeyVaultSecretReference.cs +++ b/src/Aspire.Hosting.Azure.KeyVault/AzureKeyVaultSecretReference.cs @@ -10,7 +10,7 @@ namespace Aspire.Hosting.Azure; /// /// The name of the secret. /// The Azure Key Vault resource. -internal sealed class AzureKeyVaultSecretReference(string secretName, AzureKeyVaultResource azureKeyVaultResource) : IKeyVaultSecretReference, IValueProvider, IManifestExpressionProvider +internal sealed class AzureKeyVaultSecretReference(string secretName, AzureKeyVaultResource azureKeyVaultResource) : IAzureKeyVaultSecretReference, IValueProvider, IManifestExpressionProvider { /// /// Gets the name of the secret. @@ -20,7 +20,7 @@ internal sealed class AzureKeyVaultSecretReference(string secretName, AzureKeyVa /// /// Gets the Azure Key Vault resource. /// - public IKeyVaultResource Resource => azureKeyVaultResource; + public IAzureKeyVaultResource Resource => azureKeyVaultResource; string IManifestExpressionProvider.ValueExpression => $"{{{azureKeyVaultResource.Name}.secrets.{SecretName}}}"; @@ -28,7 +28,7 @@ internal sealed class AzureKeyVaultSecretReference(string secretName, AzureKeyVa { if (azureKeyVaultResource.SecretResolver is { } secretResolver) { - return await secretResolver(secretName, cancellationToken).ConfigureAwait(false); + return await secretResolver(this, cancellationToken).ConfigureAwait(false); } throw new InvalidOperationException($"Secret '{secretName}' not found in Key Vault '{azureKeyVaultResource.Name}'."); diff --git a/src/Aspire.Hosting.Azure.PostgreSQL/AzurePostgresExtensions.cs b/src/Aspire.Hosting.Azure.PostgreSQL/AzurePostgresExtensions.cs index 59902d48d8f..60a279045d7 100644 --- a/src/Aspire.Hosting.Azure.PostgreSQL/AzurePostgresExtensions.cs +++ b/src/Aspire.Hosting.Azure.PostgreSQL/AzurePostgresExtensions.cs @@ -118,7 +118,7 @@ public static IResourceBuilder AsAzurePostgresFlexibleSe /// This requires changes to the application code to use an azure credential to authenticate with the resource. See /// https://learn.microsoft.com/azure/postgresql/flexible-server/how-to-connect-with-managed-identity#connect-using-managed-identity-in-c for more information. /// - /// You can use the method to configure the resource to use password authentication. + /// You can use the method to configure the resource to use password authentication. /// /// /// The following example creates an Azure PostgreSQL Flexible Server resource and referencing that resource in a .NET project. @@ -289,6 +289,20 @@ public static IResourceBuilder WithPassword var kv = builder.ApplicationBuilder.AddAzureKeyVault($"{builder.Resource.Name}-kv") .WithParentRelationship(builder.Resource); + // remove the KeyVault from the model if the emulator is used during run mode. + // need to do this later in case builder becomes an emulator after this method is called. + if (builder.ApplicationBuilder.ExecutionContext.IsRunMode) + { + builder.ApplicationBuilder.Eventing.Subscribe((data, token) => + { + if (builder.Resource.IsContainer()) + { + data.Model.Resources.Remove(kv.Resource); + } + return Task.CompletedTask; + }); + } + return builder.WithPasswordAuthentication(kv, userName, password); } @@ -303,7 +317,7 @@ public static IResourceBuilder WithPassword /// A reference to the builder. public static IResourceBuilder WithPasswordAuthentication( this IResourceBuilder builder, - IResourceBuilder keyVaultBuilder, + IResourceBuilder keyVaultBuilder, IResourceBuilder? userName = null, IResourceBuilder? password = null) { diff --git a/src/Aspire.Hosting.Azure.PostgreSQL/AzurePostgresFlexibleServerResource.cs b/src/Aspire.Hosting.Azure.PostgreSQL/AzurePostgresFlexibleServerResource.cs index 34ddbab1d72..0946756806c 100644 --- a/src/Aspire.Hosting.Azure.PostgreSQL/AzurePostgresFlexibleServerResource.cs +++ b/src/Aspire.Hosting.Azure.PostgreSQL/AzurePostgresFlexibleServerResource.cs @@ -33,7 +33,7 @@ public class AzurePostgresFlexibleServerResource(string name, Action - internal IKeyVaultSecretReference? ConnectionStringSecretOutput { get; set; } + internal IAzureKeyVaultSecretReference? ConnectionStringSecretOutput { get; set; } private BicepOutputReference NameOutputReference => new("name", this); diff --git a/src/Aspire.Hosting.Azure.Redis/AzureRedisCacheResource.cs b/src/Aspire.Hosting.Azure.Redis/AzureRedisCacheResource.cs index 7c7b844c4e3..ec1cdfee8a9 100644 --- a/src/Aspire.Hosting.Azure.Redis/AzureRedisCacheResource.cs +++ b/src/Aspire.Hosting.Azure.Redis/AzureRedisCacheResource.cs @@ -29,7 +29,7 @@ public class AzureRedisCacheResource(string name, Action - internal IKeyVaultSecretReference? ConnectionStringSecretOutput { get; set; } + internal IAzureKeyVaultSecretReference? ConnectionStringSecretOutput { get; set; } private BicepOutputReference NameOutputReference => new("name", this); diff --git a/src/Aspire.Hosting.Azure.Redis/AzureRedisExtensions.cs b/src/Aspire.Hosting.Azure.Redis/AzureRedisExtensions.cs index 0795cebc879..a0171036463 100644 --- a/src/Aspire.Hosting.Azure.Redis/AzureRedisExtensions.cs +++ b/src/Aspire.Hosting.Azure.Redis/AzureRedisExtensions.cs @@ -96,7 +96,7 @@ public static IResourceBuilder AsAzureRedis(this IResourceBuilder /// This requires changes to the application code to use an azure credential to authenticate with the resource. See /// https://github.com/Azure/Microsoft.Azure.StackExchangeRedis for more information. /// - /// You can use the method to configure the resource to use access key authentication. + /// You can use the method to configure the resource to use access key authentication. /// /// /// The following example creates an Azure Cache for Redis resource and referencing that resource in a .NET project. @@ -195,6 +195,20 @@ public static IResourceBuilder WithAccessKeyAuthenticat var kv = builder.ApplicationBuilder.AddAzureKeyVault($"{builder.Resource.Name}-kv") .WithParentRelationship(builder.Resource); + // remove the KeyVault from the model if the emulator is used during run mode. + // need to do this later in case builder becomes an emulator after this method is called. + if (builder.ApplicationBuilder.ExecutionContext.IsRunMode) + { + builder.ApplicationBuilder.Eventing.Subscribe((data, token) => + { + if (builder.Resource.IsContainer()) + { + data.Model.Resources.Remove(kv.Resource); + } + return Task.CompletedTask; + }); + } + return builder.WithAccessKeyAuthentication(kv); } @@ -204,7 +218,7 @@ public static IResourceBuilder WithAccessKeyAuthenticat /// The Azure Cache for Redis resource builder. /// The Azure Key Vault resource builder where the connection string used to connect to this AzureRedisCacheResource will be stored. /// A reference to the builder. - public static IResourceBuilder WithAccessKeyAuthentication(this IResourceBuilder builder, IResourceBuilder keyVaultBuilder) + public static IResourceBuilder WithAccessKeyAuthentication(this IResourceBuilder builder, IResourceBuilder keyVaultBuilder) { ArgumentNullException.ThrowIfNull(builder); ArgumentNullException.ThrowIfNull(keyVaultBuilder); diff --git a/src/Aspire.Hosting.Azure/AzureResourcePreparer.cs b/src/Aspire.Hosting.Azure/AzureResourcePreparer.cs index c3af1161d3f..a6d47daccd7 100644 --- a/src/Aspire.Hosting.Azure/AzureResourcePreparer.cs +++ b/src/Aspire.Hosting.Azure/AzureResourcePreparer.cs @@ -362,7 +362,7 @@ private static void ProcessAzureReferences(HashSet azureReferenc return; } - if (value is IKeyVaultSecretReference keyVaultSecretReference) + if (value is IAzureKeyVaultSecretReference keyVaultSecretReference) { azureReferences.Add(keyVaultSecretReference.Resource); return; diff --git a/src/Aspire.Hosting.Azure/IKeyVaultResource.cs b/src/Aspire.Hosting.Azure/IAzureKeyVaultResource.cs similarity index 80% rename from src/Aspire.Hosting.Azure/IKeyVaultResource.cs rename to src/Aspire.Hosting.Azure/IAzureKeyVaultResource.cs index 9226de80447..432b072dcf1 100644 --- a/src/Aspire.Hosting.Azure/IKeyVaultResource.cs +++ b/src/Aspire.Hosting.Azure/IAzureKeyVaultResource.cs @@ -8,7 +8,7 @@ namespace Aspire.Hosting.Azure; /// /// Represents a resource that represents an Azure Key Vault. /// -public interface IKeyVaultResource : IResource, IAzureResource +public interface IAzureKeyVaultResource : IResource, IAzureResource { /// /// Gets the output reference that represents the vault uri for the Azure Key Vault resource. @@ -23,12 +23,12 @@ public interface IKeyVaultResource : IResource, IAzureResource /// /// Gets or sets the secret resolver function used to resolve secrets at runtime. /// - Func>? SecretResolver { get; set; } + Func>? SecretResolver { get; set; } /// /// Gets a secret reference for the specified secret name. /// /// The name of the secret. /// A reference to the secret. - IKeyVaultSecretReference GetSecretReference(string secretName); + IAzureKeyVaultSecretReference GetSecretReference(string secretName); } diff --git a/src/Aspire.Hosting.Azure/IKeyVaultSecretReference.cs b/src/Aspire.Hosting.Azure/IAzureKeyVaultSecretReference.cs similarity index 78% rename from src/Aspire.Hosting.Azure/IKeyVaultSecretReference.cs rename to src/Aspire.Hosting.Azure/IAzureKeyVaultSecretReference.cs index 70f1cb30d58..b0a33b5a536 100644 --- a/src/Aspire.Hosting.Azure/IKeyVaultSecretReference.cs +++ b/src/Aspire.Hosting.Azure/IAzureKeyVaultSecretReference.cs @@ -8,7 +8,7 @@ namespace Aspire.Hosting.Azure; /// /// Represents a reference to a secret in an Azure Key Vault resource. /// -public interface IKeyVaultSecretReference : IValueProvider, IManifestExpressionProvider +public interface IAzureKeyVaultSecretReference : IValueProvider, IManifestExpressionProvider { /// /// Gets the name of the secret. @@ -18,5 +18,5 @@ public interface IKeyVaultSecretReference : IValueProvider, IManifestExpressionP /// /// Gets the Azure Key Vault resource. /// - IKeyVaultResource Resource { get; } -} \ No newline at end of file + IAzureKeyVaultResource Resource { get; } +} diff --git a/src/Aspire.Hosting.Azure/Provisioning/Provisioners/BicepProvisioner.cs b/src/Aspire.Hosting.Azure/Provisioning/Provisioners/BicepProvisioner.cs index 12581f26de2..d4a0c90d5cb 100644 --- a/src/Aspire.Hosting.Azure/Provisioning/Provisioners/BicepProvisioner.cs +++ b/src/Aspire.Hosting.Azure/Provisioning/Provisioners/BicepProvisioner.cs @@ -278,15 +278,15 @@ await notificationService.PublishUpdateAsync(resource, state => } // Populate secret outputs from key vault (if any) - if (resource is IKeyVaultResource kvr) + if (resource is IAzureKeyVaultResource kvr) { var vaultUri = resource.Outputs[kvr.VaultUriOutputReference.Name] as string ?? throw new InvalidOperationException($"{kvr.VaultUriOutputReference.Name} not found in outputs."); // Set the client for resolving secrets at runtime var client = new SecretClient(new(vaultUri), context.Credential); - kvr.SecretResolver = async (secretName, ct) => + kvr.SecretResolver = async (secretRef, ct) => { - var secret = await client.GetSecretAsync(secretName, cancellationToken: ct).ConfigureAwait(false); + var secret = await client.GetSecretAsync(secretRef.SecretName, cancellationToken: ct).ConfigureAwait(false); return secret.Value.Value; }; } diff --git a/src/Aspire.Hosting.PostgreSQL/PostgresBuilderExtensions.cs b/src/Aspire.Hosting.PostgreSQL/PostgresBuilderExtensions.cs index fc4b463bd24..e2d22a4aedb 100644 --- a/src/Aspire.Hosting.PostgreSQL/PostgresBuilderExtensions.cs +++ b/src/Aspire.Hosting.PostgreSQL/PostgresBuilderExtensions.cs @@ -419,7 +419,7 @@ public static IResourceBuilder WithCreationScript(this ArgumentNullException.ThrowIfNull(builder); ArgumentNullException.ThrowIfNull(script); - builder.WithAnnotation(new CreationScriptAnnotation(script)); + builder.WithAnnotation(new PostgresCreateDatabaseScriptAnnotation(script)); return builder; } @@ -493,7 +493,7 @@ private static string WritePgAdminServerJson(IEnumerable private static async Task CreateDatabaseAsync(NpgsqlConnection npgsqlConnection, PostgresDatabaseResource npgsqlDatabase, IServiceProvider serviceProvider, CancellationToken cancellationToken) { - var scriptAnnotation = npgsqlDatabase.Annotations.OfType().LastOrDefault(); + var scriptAnnotation = npgsqlDatabase.Annotations.OfType().LastOrDefault(); try { diff --git a/src/Aspire.Hosting.PostgreSQL/PostgresCreateDatabaseScriptAnnotation.cs b/src/Aspire.Hosting.PostgreSQL/PostgresCreateDatabaseScriptAnnotation.cs new file mode 100644 index 00000000000..54e64354d71 --- /dev/null +++ b/src/Aspire.Hosting.PostgreSQL/PostgresCreateDatabaseScriptAnnotation.cs @@ -0,0 +1,27 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Hosting.ApplicationModel; + +namespace Aspire.Hosting.Postgres; + +/// +/// Represents an annotation for defining a script to create a database in PostgreSQL. +/// +internal sealed class PostgresCreateDatabaseScriptAnnotation : IResourceAnnotation +{ + /// + /// Initializes a new instance of the class. + /// + /// The script used to create the database. + public PostgresCreateDatabaseScriptAnnotation(string script) + { + ArgumentNullException.ThrowIfNull(script); + Script = script; + } + + /// + /// Gets the script used to create the database. + /// + public string Script { get; } +} diff --git a/src/Aspire.Hosting.SqlServer/SqlServerBuilderExtensions.cs b/src/Aspire.Hosting.SqlServer/SqlServerBuilderExtensions.cs index 977ea4c7cd3..a8271f79279 100644 --- a/src/Aspire.Hosting.SqlServer/SqlServerBuilderExtensions.cs +++ b/src/Aspire.Hosting.SqlServer/SqlServerBuilderExtensions.cs @@ -195,7 +195,7 @@ public static IResourceBuilder WithCreationScript(thi ArgumentNullException.ThrowIfNull(builder); ArgumentNullException.ThrowIfNull(script); - builder.WithAnnotation(new CreationScriptAnnotation(script)); + builder.WithAnnotation(new SqlServerCreateDatabaseScriptAnnotation(script)); return builder; } @@ -204,7 +204,7 @@ private static async Task CreateDatabaseAsync(SqlConnection sqlConnection, SqlSe { try { - var scriptAnnotation = sqlDatabase.Annotations.OfType().LastOrDefault(); + var scriptAnnotation = sqlDatabase.Annotations.OfType().LastOrDefault(); if (scriptAnnotation?.Script == null) { diff --git a/src/Aspire.Hosting.SqlServer/SqlServerCreateDatabaseScriptAnnotation.cs b/src/Aspire.Hosting.SqlServer/SqlServerCreateDatabaseScriptAnnotation.cs new file mode 100644 index 00000000000..7591d8fe97c --- /dev/null +++ b/src/Aspire.Hosting.SqlServer/SqlServerCreateDatabaseScriptAnnotation.cs @@ -0,0 +1,27 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Hosting.ApplicationModel; + +namespace Aspire.Hosting; + +/// +/// Represents an annotation for defining a script to create a database in SQL Server. +/// +internal sealed class SqlServerCreateDatabaseScriptAnnotation : IResourceAnnotation +{ + /// + /// Initializes a new instance of the class. + /// + /// The script used to create the database. + public SqlServerCreateDatabaseScriptAnnotation(string script) + { + ArgumentNullException.ThrowIfNull(script); + Script = script; + } + + /// + /// Gets the script used to create the database. + /// + public string Script { get; } +} diff --git a/src/Aspire.Hosting/ApplicationModel/CreationScriptAnnotation.cs b/src/Aspire.Hosting/ApplicationModel/CreationScriptAnnotation.cs deleted file mode 100644 index f66abcecf83..00000000000 --- a/src/Aspire.Hosting/ApplicationModel/CreationScriptAnnotation.cs +++ /dev/null @@ -1,25 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -namespace Aspire.Hosting.ApplicationModel; - -/// -/// Represents an annotation for defining a script to create a resource. -/// -public sealed class CreationScriptAnnotation : IResourceAnnotation -{ - /// - /// Initializes a new instance of the class. - /// - /// The script used to create the resource. - public CreationScriptAnnotation(string script) - { - ArgumentNullException.ThrowIfNull(script); - Script = script; - } - - /// - /// Gets the script used to create the resource. - /// - public string Script { get; } -} diff --git a/tests/Aspire.Hosting.Azure.Tests/AzureBicepResourceTests.cs b/tests/Aspire.Hosting.Azure.Tests/AzureBicepResourceTests.cs index b25ccd148fe..5cd01f6b256 100644 --- a/tests/Aspire.Hosting.Azure.Tests/AzureBicepResourceTests.cs +++ b/tests/Aspire.Hosting.Azure.Tests/AzureBicepResourceTests.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Net.Sockets; +using System.Runtime.CompilerServices; using System.Text.Json.Nodes; using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.Lifecycle; @@ -237,6 +238,24 @@ public async Task AddAzureCosmosDBEmulator() Assert.Equal(cs, await ((IResourceWithConnectionString)cosmos.Resource).GetConnectionStringAsync()); } + [Fact] + public async Task AddAzureCosmosDB_WithAccessKeyAuthentication_NoKeyVaultWithEmulator() + { + using var builder = TestDistributedApplicationBuilder.Create(); + + builder.AddAzureCosmosDB("cosmos").WithAccessKeyAuthentication().RunAsEmulator(); + +#pragma warning disable ASPIRECOSMOSDB001 + builder.AddAzureCosmosDB("cosmos2").WithAccessKeyAuthentication().RunAsPreviewEmulator(); +#pragma warning restore ASPIRECOSMOSDB001 + + var app = builder.Build(); + var model = app.Services.GetRequiredService(); + await ExecuteBeforeStartHooksAsync(app, CancellationToken.None); + + Assert.Empty(model.Resources.OfType()); + } + [Theory] [InlineData(null)] [InlineData("mykeyvault")] @@ -264,16 +283,24 @@ public async Task AddAzureCosmosDBViaRunMode_WithAccessKeyAuthentication(string? var db = cosmos.AddCosmosDatabase("db", databaseName: "mydatabase"); db.AddContainer("container", "mypartitionkeypath", containerName: "mycontainer"); - var kv = builder.CreateResourceBuilder(kvName); + var app = builder.Build(); + + await ExecuteBeforeStartHooksAsync(app, CancellationToken.None); + + var model = app.Services.GetRequiredService(); + + var kv = model.Resources.OfType().Single(); + + Assert.Equal(kvName, kv.Name); var secrets = new Dictionary { ["connectionstrings--cosmos"] = "mycosmosconnectionstring" }; - kv.Resource.SecretResolver = (name, _) => + kv.SecretResolver = (secretRef, _) => { - if (!secrets.TryGetValue(name, out var value)) + if (!secrets.TryGetValue(secretRef.SecretName, out var value)) { return Task.FromResult(null); } @@ -533,9 +560,9 @@ public async Task AddAzureCosmosDBViaPublishMode_WithAccessKeyAuthentication(str ["connectionstrings--cosmos"] = "mycosmosconnectionstring" }; - kv.Resource.SecretResolver = (name, _) => + kv.Resource.SecretResolver = (secretRef, _) => { - if (!secrets.TryGetValue(name, out var value)) + if (!secrets.TryGetValue(secretRef.SecretName, out var value)) { return Task.FromResult(null); } @@ -3130,6 +3157,9 @@ public async Task InfrastructureCanBeMutatedAfterCreation() Assert.Equal(expectedBicep, bicep); } + [UnsafeAccessor(UnsafeAccessorKind.Method, Name = "ExecuteBeforeStartHooksAsync")] + private static extern Task ExecuteBeforeStartHooksAsync(DistributedApplication app, CancellationToken cancellationToken); + private sealed class ProjectA : IProjectMetadata { public string ProjectPath => "projectA"; diff --git a/tests/Aspire.Hosting.Azure.Tests/AzureCosmosDBExtensionsTests.cs b/tests/Aspire.Hosting.Azure.Tests/AzureCosmosDBExtensionsTests.cs index 7f7e2d2e18a..4c5475efbec 100644 --- a/tests/Aspire.Hosting.Azure.Tests/AzureCosmosDBExtensionsTests.cs +++ b/tests/Aspire.Hosting.Azure.Tests/AzureCosmosDBExtensionsTests.cs @@ -106,16 +106,26 @@ public void AzureCosmosDBHasCorrectConnectionStrings_ForAccountEndpoint() Assert.Equal("AccountEndpoint={cosmos.outputs.connectionString};Database=db1;Container=container1", container1.Resource.ConnectionStringExpression.ValueExpression); } - [Fact] - public void AzureCosmosDBHasCorrectConnectionStrings() + [Theory] + [InlineData(true)] + [InlineData(false)] + public void AzureCosmosDBHasCorrectConnectionStrings(bool useAccessKeyAuth) { using var builder = TestDistributedApplicationBuilder.Create(); var cosmos = builder.AddAzureCosmosDB("cosmos").RunAsEmulator(); + if (useAccessKeyAuth) + { + cosmos.WithAccessKeyAuthentication(); + } var db1 = cosmos.AddCosmosDatabase("db1"); var container1 = db1.AddContainer("container1", "id"); var cosmos1 = builder.AddAzureCosmosDB("cosmos1").RunAsEmulator(); + if (useAccessKeyAuth) + { + cosmos1.WithAccessKeyAuthentication(); + } var db2 = cosmos1.AddCosmosDatabase("db2", "db"); var container2 = db2.AddContainer("container2", "id", "container"); diff --git a/tests/Aspire.Hosting.Azure.Tests/AzurePostgresExtensionsTests.cs b/tests/Aspire.Hosting.Azure.Tests/AzurePostgresExtensionsTests.cs index e8c90e020b6..cc011572937 100644 --- a/tests/Aspire.Hosting.Azure.Tests/AzurePostgresExtensionsTests.cs +++ b/tests/Aspire.Hosting.Azure.Tests/AzurePostgresExtensionsTests.cs @@ -145,6 +145,20 @@ param principalName string Assert.Equal(expectedBicep, postgresRolesManifest.BicepText); } + [Fact] + public async Task AddAzurePostgresFlexibleServer_WithPasswordAuthentication_NoKeyVaultWithContainer() + { + using var builder = TestDistributedApplicationBuilder.Create(); + + builder.AddAzurePostgresFlexibleServer("pg").WithPasswordAuthentication().RunAsContainer(); + + var app = builder.Build(); + var model = app.Services.GetRequiredService(); + await ExecuteBeforeStartHooksAsync(app, CancellationToken.None); + + Assert.Empty(model.Resources.OfType()); + } + [Theory] [InlineData(true, true, null)] [InlineData(true, true, "mykeyvault")] diff --git a/tests/Aspire.Hosting.Azure.Tests/AzureRedisExtensionsTests.cs b/tests/Aspire.Hosting.Azure.Tests/AzureRedisExtensionsTests.cs index 53dfe5b9770..bc3d6481fda 100644 --- a/tests/Aspire.Hosting.Azure.Tests/AzureRedisExtensionsTests.cs +++ b/tests/Aspire.Hosting.Azure.Tests/AzureRedisExtensionsTests.cs @@ -99,6 +99,20 @@ param principalName string Assert.Equal(expectedBicep, redisRolesManifest.BicepText); } + [Fact] + public async Task AddAzureRedis_WithAccessKeyAuthentication_NoKeyVaultWithContainer() + { + using var builder = TestDistributedApplicationBuilder.Create(); + + builder.AddAzureRedis("redis").WithAccessKeyAuthentication().RunAsContainer(); + + var app = builder.Build(); + var model = app.Services.GetRequiredService(); + await ExecuteBeforeStartHooksAsync(app, CancellationToken.None); + + Assert.Empty(model.Resources.OfType()); + } + [Theory] [InlineData(null)] [InlineData("mykeyvault")]