diff --git a/Aspire.slnx b/Aspire.slnx index 7f554e3e7ce..99b85801b0b 100644 --- a/Aspire.slnx +++ b/Aspire.slnx @@ -172,6 +172,9 @@ + + + @@ -415,6 +418,7 @@ + diff --git a/playground/DotnetTool/DotnetTool.AppHost/AppHost.cs b/playground/DotnetTool/DotnetTool.AppHost/AppHost.cs new file mode 100644 index 00000000000..0bb329bcca5 --- /dev/null +++ b/playground/DotnetTool/DotnetTool.AppHost/AppHost.cs @@ -0,0 +1,122 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +using Microsoft.Extensions.DependencyInjection; +#pragma warning disable ASPIREDOTNETTOOL // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. + +var builder = DistributedApplication.CreateBuilder(args); + +var simpleUsage = builder.AddDotnetTool("simpleUsage", "dotnet-ef"); + +var wildcardVersion = builder.AddDotnetTool("wildcard", "dotnet-ef") + .WithToolVersion("10.0.*") + .WithParentRelationship(simpleUsage); + +var preRelease = builder.AddDotnetTool("prerelease", "dotnet-ef") + .WithToolPrerelease() + .WithParentRelationship(simpleUsage); + +// Multiple versions +var differentVersion = builder.AddDotnetTool("sameToolDifferentVersion1", "dotnet-dump") + .WithArgs("--version") + .WithToolVersion("9.0.652701"); +builder.AddDotnetTool("sameToolDifferentVersion2", "dotnet-dump") + .WithToolVersion("9.0.621003") + .WithArgs("--version") + .WithParentRelationship(differentVersion); + +// Concurrency +IResourceBuilder? concurrencyParent = null; +for (int i = 0; i < 5; i++) +{ + var concurrency = builder.AddDotnetTool($"sametoolconcurrency-{i}", "dotnet-trace") + .WithArgs("--version"); + + if (concurrencyParent == null) + { + concurrencyParent = concurrency; + } + else + { + concurrency.WithParentRelationship(concurrencyParent); + } +} + +// Substitution +var substituted = builder.AddDotnetTool("substituted", "dotnet-ef") + .WithCommand("calc") + .WithIconName("Calculator") + .WithExplicitStart(); +foreach(var toolAnnotation in substituted.Resource.Annotations.OfType().ToList()) +{ + substituted.Resource.Annotations.Remove(toolAnnotation); +} + +// Fake Offline by using "empty" package feeds +var fakeSourcesPath = Path.Combine(Path.GetTempPath(), "does-not-exist", Guid.NewGuid().ToString()); +var offline = builder.AddDotnetTool("offlineSimpleUsage", "dotnet-ef") + .WithToolSource(fakeSourcesPath) + .WithToolIgnoreExistingFeeds() + .WithToolIgnoreFailedSources() + ; + +builder.AddDotnetTool("offlineWildcard", "dotnet-ef") + .WithToolVersion("10.0.*") + .WithParentRelationship(offline) + .WithToolSource(fakeSourcesPath) + .WithToolIgnoreExistingFeeds() + .WithToolIgnoreFailedSources(); + +builder.AddDotnetTool("offlinePrerelease", "dotnet-ef") + .WithToolPrerelease() + .WithParentRelationship(offline) + .WithToolSource(fakeSourcesPath) + .WithToolIgnoreExistingFeeds() + .WithToolIgnoreFailedSources(); + +var secret = builder.AddParameter("secret", "Shhhhhhh", secret: true); + +// Secrets +builder.AddDotnetTool("secretArg", "dotnet-ef") + .WithArgs("--version") + .WithArgs(secret); + +// Some issues only show up when installing for first time, rather than using existing downloaded versions +// Use a specific NUGET_PACKAGES path for these playground tools, so we can easily reset them +builder.Eventing.Subscribe(async (evt, _) => +{ + var nugetPackagesPath = Path.Join(evt.Services.GetRequiredService().BasePath, "nuget"); + + foreach (var resource in builder.Resources.OfType()) + { + builder.CreateResourceBuilder(resource) + .WithEnvironment("NUGET_PACKAGES", nugetPackagesPath) + .WithCommand("reset", "Reset Packages", ctx => + { + try + { + Directory.Delete(nugetPackagesPath, true); + return Task.FromResult(CommandResults.Success()); + } + catch (Exception ex) + { + return Task.FromResult(CommandResults.Failure(ex)); + } + }, new CommandOptions + { + IconName = "Delete" + }); + } +}); + +#if !SKIP_DASHBOARD_REFERENCE +// This project is only added in playground projects to support development/debugging +// of the dashboard. It is not required in end developer code. Comment out this code +// or build with `/p:SkipDashboardReference=true`, to test end developer +// dashboard launch experience, Refer to Directory.Build.props for the path to +// the dashboard binary (defaults to the Aspire.Dashboard bin output in the +// artifacts dir). +builder.AddProject(KnownResourceNames.AspireDashboard); +#endif + +builder.Build().Run(); + diff --git a/playground/DotnetTool/DotnetTool.AppHost/DotnetTool.AppHost.csproj b/playground/DotnetTool/DotnetTool.AppHost/DotnetTool.AppHost.csproj new file mode 100644 index 00000000000..e0f1b2bf1e9 --- /dev/null +++ b/playground/DotnetTool/DotnetTool.AppHost/DotnetTool.AppHost.csproj @@ -0,0 +1,19 @@ + + + + Exe + $(DefaultTargetFramework) + enable + enable + true + + + + + + + + + + + diff --git a/playground/DotnetTool/DotnetTool.AppHost/Properties/launchSettings.json b/playground/DotnetTool/DotnetTool.AppHost/Properties/launchSettings.json new file mode 100644 index 00000000000..be00d6140cb --- /dev/null +++ b/playground/DotnetTool/DotnetTool.AppHost/Properties/launchSettings.json @@ -0,0 +1,45 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "profiles": { + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:16179;http://localhost:16180", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:17119", + "ASPIRE_DASHBOARD_MCP_ENDPOINT_URL": "https://localhost:18036", + "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:17119", + "ASPIRE_SHOW_DASHBOARD_RESOURCES": "true" + } + }, + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:16180", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:17120", + "ASPIRE_DASHBOARD_MCP_ENDPOINT_URL": "http://localhost:18037", + "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:17120", + "ASPIRE_SHOW_DASHBOARD_RESOURCES": "true", + "ASPIRE_ALLOW_UNSECURED_TRANSPORT": "true" + } + }, + "generate-manifest": { + "commandName": "Project", + "dotnetRunMessages": true, + "commandLineArgs": "--publisher manifest --output-path aspire-manifest.json", + "applicationUrl": "http://localhost:16180", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:17120" + } + } + } +} diff --git a/playground/DotnetTool/DotnetTool.AppHost/appsettings.Development.json b/playground/DotnetTool/DotnetTool.AppHost/appsettings.Development.json new file mode 100644 index 00000000000..0c208ae9181 --- /dev/null +++ b/playground/DotnetTool/DotnetTool.AppHost/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/playground/DotnetTool/DotnetTool.AppHost/appsettings.json b/playground/DotnetTool/DotnetTool.AppHost/appsettings.json new file mode 100644 index 00000000000..31c092aa450 --- /dev/null +++ b/playground/DotnetTool/DotnetTool.AppHost/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Aspire.Hosting.Dcp": "Warning" + } + } +} diff --git a/src/Aspire.Dashboard/Model/ResourceSourceViewModel.cs b/src/Aspire.Dashboard/Model/ResourceSourceViewModel.cs index 788c8e56e7d..624fc5b5d93 100644 --- a/src/Aspire.Dashboard/Model/ResourceSourceViewModel.cs +++ b/src/Aspire.Dashboard/Model/ResourceSourceViewModel.cs @@ -16,11 +16,15 @@ public class ResourceSourceViewModel(string value, List? content { var commandLineInfo = GetCommandLineInfo(resource); - // NOTE projects are also executables, so we have to check for projects first + // NOTE project and tools are also executables, so check for those first if (resource.IsProject() && resource.TryGetProjectPath(out var projectPath)) { return CreateResourceSourceViewModel(Path.GetFileName(projectPath), projectPath, commandLineInfo); } + if (resource.IsTool() && resource.TryGetToolPackage(out var toolPackage)) + { + return CreateResourceSourceViewModel(toolPackage, toolPackage, commandLineInfo); + } if (resource.TryGetExecutablePath(out var executablePath)) { diff --git a/src/Aspire.Dashboard/Model/ResourceViewModelExtensions.cs b/src/Aspire.Dashboard/Model/ResourceViewModelExtensions.cs index adaf39057b9..5e7b62992b5 100644 --- a/src/Aspire.Dashboard/Model/ResourceViewModelExtensions.cs +++ b/src/Aspire.Dashboard/Model/ResourceViewModelExtensions.cs @@ -19,6 +19,11 @@ public static bool IsProject(this ResourceViewModel resource) return StringComparers.ResourceType.Equals(resource.ResourceType, KnownResourceTypes.Project); } + public static bool IsTool(this ResourceViewModel resource) + { + return StringComparers.ResourceType.Equals(resource.ResourceType, KnownResourceTypes.Tool); + } + public static bool IsExecutable(this ResourceViewModel resource, bool allowSubtypes) { if (StringComparers.ResourceType.Equals(resource.ResourceType, KnownResourceTypes.Executable)) @@ -48,6 +53,11 @@ public static bool TryGetProjectPath(this ResourceViewModel resource, [NotNullWh { return resource.TryGetCustomDataString(KnownProperties.Project.Path, out projectPath); } + + public static bool TryGetToolPackage(this ResourceViewModel resource, [NotNullWhen(returnValue: true)] out string? projectPath) + { + return resource.TryGetCustomDataString(KnownProperties.Tool.Package, out projectPath); + } public static bool TryGetExecutablePath(this ResourceViewModel resource, [NotNullWhen(returnValue: true)] out string? executablePath) { diff --git a/src/Aspire.Hosting/ApplicationModel/AppLaunchArgumentAnnotation.cs b/src/Aspire.Hosting/ApplicationModel/AppLaunchArgumentAnnotation.cs index ee11233189d..9c7eecf23e4 100644 --- a/src/Aspire.Hosting/ApplicationModel/AppLaunchArgumentAnnotation.cs +++ b/src/Aspire.Hosting/ApplicationModel/AppLaunchArgumentAnnotation.cs @@ -9,7 +9,7 @@ namespace Aspire.Hosting.ApplicationModel; /// Represents a container or project application launch argument. /// [DebuggerDisplay("Type = {GetType().Name,nq}, Argument = {Argument}, IsSensitive = {IsSensitive}")] -internal sealed class AppLaunchArgumentAnnotation(string argument, bool isSensitive) : IResourceAnnotation +internal sealed class AppLaunchArgumentAnnotation(string argument, bool isSensitive) { /// /// The evaluated launch argument. diff --git a/src/Aspire.Hosting/ApplicationModel/DotnetToolAnnotation.cs b/src/Aspire.Hosting/ApplicationModel/DotnetToolAnnotation.cs new file mode 100644 index 00000000000..e62dbc56110 --- /dev/null +++ b/src/Aspire.Hosting/ApplicationModel/DotnetToolAnnotation.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 System.Diagnostics.CodeAnalysis; + +namespace Aspire.Hosting.ApplicationModel; +/// +/// Represents an annotation for dotnet tool resources. +/// +[Experimental("ASPIREDOTNETTOOL", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] +public class DotnetToolAnnotation : IResourceAnnotation +{ + /// + /// The NuGet package ID of the .NET tool to execute. You can optionally specify a version using the @ syntax, for example dotnetsay@2.1. + /// + public required string PackageId { get; set; } + + /// + /// The version of the tool package to install. + /// + public string? Version { get; set; } + + /// + /// Allows prerelease packages to be selected when resolving the version to install. + /// + public bool Prerelease { get; set; } + + /// + /// NuGet package sources to use during installation + /// + public List Sources { get; } = []; + + /// + /// Are custom sources used in addition or instead of existing feeds. + /// + /// + /// This value has no impact if is empty. + /// + public bool IgnoreExistingFeeds { get; set; } + + /// + /// Treats package source failures as warnings. + /// + public bool IgnoreFailedSources { get; set; } +} diff --git a/src/Aspire.Hosting/ApplicationModel/DotnetToolResource.cs b/src/Aspire.Hosting/ApplicationModel/DotnetToolResource.cs new file mode 100644 index 00000000000..5d52bf12f66 --- /dev/null +++ b/src/Aspire.Hosting/ApplicationModel/DotnetToolResource.cs @@ -0,0 +1,40 @@ +// 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.Diagnostics.CodeAnalysis; + +namespace Aspire.Hosting.ApplicationModel; + +/// +/// A resource that represents a specified dotnet tool. +/// +/// +/// This class is used to define and manage resources for .NET CLI tools. It associates a tool's name and +/// command with its package ID, and ensures that the required metadata is properly annotated. +/// +[Experimental("ASPIREDOTNETTOOL", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] +[DebuggerDisplay("Type = {GetType().Name,nq}, Name = {Name}, Tool = {ToolConfiguration?.PackageId}")] +public class DotnetToolResource : ExecutableResource +{ + /// + /// Initializes a new instance of the class. + /// + /// The name of the resource. + /// The package id of the tool. + public DotnetToolResource(string name, string packageId) + : base(name, "dotnet", ".") + { + ArgumentException.ThrowIfNullOrWhiteSpace(packageId, nameof(packageId)); + Annotations.Add(new DotnetToolAnnotation { PackageId = packageId }); + } + + internal DotnetToolAnnotation? ToolConfiguration + { + get + { + this.TryGetLastAnnotation(out var toolConfig); + return toolConfig; + } + } +} diff --git a/src/Aspire.Hosting/Dcp/DcpExecutor.cs b/src/Aspire.Hosting/Dcp/DcpExecutor.cs index b0bff668cc9..f8bfd870a99 100644 --- a/src/Aspire.Hosting/Dcp/DcpExecutor.cs +++ b/src/Aspire.Hosting/Dcp/DcpExecutor.cs @@ -1700,14 +1700,15 @@ private async Task CreateExecutableAsync(RenderedModelResource er, ILogger resou } var launchArgs = BuildLaunchArgs(er, spec, configuration.Arguments); - var executableArgs = launchArgs.Where(a => !a.AnnotationOnly).Select(a => a.Value).ToList(); + var executableArgs = launchArgs.Where(a => a.Executable).Select(a => a.Value).ToList(); + var displayArgs = launchArgs.Where(a => a.Display).ToList(); if (executableArgs.Count > 0) { spec.Args ??= []; spec.Args.AddRange(executableArgs); } // Arg annotations are what is displayed in the dashboard. - er.DcpResource.SetAnnotationAsObjectList(CustomResource.ResourceAppArgsAnnotation, launchArgs.Select(a => new AppLaunchArgumentAnnotation(a.Value, isSensitive: a.IsSensitive))); + er.DcpResource.SetAnnotationAsObjectList(CustomResource.ResourceAppArgsAnnotation, displayArgs.Select(a => new AppLaunchArgumentAnnotation(a.Value, isSensitive: a.IsSensitive))); spec.Env = configuration.EnvironmentVariables.Select(kvp => new EnvVar { Name = kvp.Key, Value = kvp.Value }).ToList(); @@ -1724,13 +1725,13 @@ private async Task CreateExecutableAsync(RenderedModelResource er, ILogger resou } } - private static List<(string Value, bool IsSensitive, bool AnnotationOnly)> BuildLaunchArgs(RenderedModelResource er, ExecutableSpec spec, IEnumerable<(string Value, bool IsSensitive)> appHostArgs) + private static List<(string Value, bool IsSensitive, bool Executable, bool Display)> BuildLaunchArgs(RenderedModelResource er, ExecutableSpec spec, IEnumerable<(string Value, bool IsSensitive)> appHostArgs) { // Launch args is the final list of args that are displayed in the UI and possibly added to the executable spec. // They're built from app host resource model args and any args in the effective launch profile. // Follows behavior in the IDE execution spec when in IDE execution mode: - // https://github.com/dotnet/aspire/blob/main/docs/specs/IDE-execution.md#launch-profile-processing-project-launch-configuration - var launchArgs = new List<(string Value, bool IsSensitive, bool AnnotationOnly)>(); + // https://github.com/dotnet/aspire/blob/main/docs/specs/IDE-execution.md#project-launch-configuration-type-project + var launchArgs = new List<(string Value, bool IsSensitive, bool Executable, bool Display)>(); // If the executable is a project then include any command line args from the launch profile. if (er.ModelResource is ProjectResource project) @@ -1742,7 +1743,7 @@ private async Task CreateExecutableAsync(RenderedModelResource er, ILogger resou { // When the .NET project is launched from an IDE the launch profile args are automatically added. // We still want to display the args in the dashboard so only add them to the custom arg annotations. - var annotationOnly = spec.ExecutionType == ExecutionType.IDE; + var executableArg = spec.ExecutionType != ExecutionType.IDE; var launchProfileArgs = GetLaunchProfileArgs(project.GetEffectiveLaunchProfile()?.LaunchProfile); if (launchProfileArgs.Count > 0 && appHostArgs.Any()) @@ -1751,12 +1752,24 @@ private async Task CreateExecutableAsync(RenderedModelResource er, ILogger resou launchProfileArgs.Insert(0, "--"); } - launchArgs.AddRange(launchProfileArgs.Select(a => (a, isSensitive: false, annotationOnly))); + launchArgs.AddRange(launchProfileArgs.Select(a => (a, isSensitive: false, executableArg, true))); } } +#pragma warning disable ASPIREDOTNETTOOL // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. + else if (er.ModelResource is DotnetToolResource tool) + { + var argSeparator = appHostArgs.Select((a, i) => (index: i, value: a.Value)) + .FirstOrDefault(x => x.value == DotnetToolResourceExtensions.ArgumentSeparator); + + var args = appHostArgs.Select((a, i) => (arg : a, display : i > argSeparator.index)); + launchArgs.AddRange(args.Select(x => (x.arg.Value, x.arg.IsSensitive, true, x.display))); + return launchArgs; + + } +#pragma warning restore ASPIREDOTNETTOOL // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. // In the situation where args are combined (process execution) the app host args are added after the launch profile args. - launchArgs.AddRange(appHostArgs.Select(a => (a.Value, a.IsSensitive, annotationOnly: false))); + launchArgs.AddRange(appHostArgs.Select(a => (a.Value, a.IsSensitive, true, true))); return launchArgs; } diff --git a/src/Aspire.Hosting/Dcp/ResourceSnapshotBuilder.cs b/src/Aspire.Hosting/Dcp/ResourceSnapshotBuilder.cs index fcf92ab4bf2..73ba9814d7d 100644 --- a/src/Aspire.Hosting/Dcp/ResourceSnapshotBuilder.cs +++ b/src/Aspire.Hosting/Dcp/ResourceSnapshotBuilder.cs @@ -44,7 +44,7 @@ public CustomResourceSnapshot ToSnapshot(Container container, CustomResourceSnap return previous with { - ResourceType = KnownResourceTypes.Container, + ResourceType = previous.ResourceType ?? KnownResourceTypes.Container, State = state, // Map a container exit code of -1 (unknown) to null ExitCode = container.Status?.ExitCode is null or Conventions.UnknownExitCode ? null : container.Status.ExitCode, @@ -108,7 +108,7 @@ public CustomResourceSnapshot ToSnapshot(ContainerExec executable, CustomResourc return previous with { - ResourceType = KnownResourceTypes.Executable, + ResourceType = previous.ResourceType ?? KnownResourceTypes.Executable, State = state, ExitCode = executable.Status?.ExitCode, Properties = previous.Properties.SetResourcePropertyRange([ @@ -159,7 +159,7 @@ public CustomResourceSnapshot ToSnapshot(Executable executable, CustomResourceSn { return previous with { - ResourceType = KnownResourceTypes.Project, + ResourceType = previous.ResourceType ?? KnownResourceTypes.Project, State = state, ExitCode = executable.Status?.ExitCode, Properties = previous.Properties.SetResourcePropertyRange([ @@ -183,7 +183,7 @@ public CustomResourceSnapshot ToSnapshot(Executable executable, CustomResourceSn return previous with { - ResourceType = KnownResourceTypes.Executable, + ResourceType = previous.ResourceType ?? KnownResourceTypes.Executable, State = state, ExitCode = executable.Status?.ExitCode, Properties = previous.Properties.SetResourcePropertyRange([ diff --git a/src/Aspire.Hosting/DotnetToolResourceExtensions.cs b/src/Aspire.Hosting/DotnetToolResourceExtensions.cs new file mode 100644 index 00000000000..74616db6cde --- /dev/null +++ b/src/Aspire.Hosting/DotnetToolResourceExtensions.cs @@ -0,0 +1,193 @@ +// 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.CodeAnalysis; +using Aspire.Dashboard.Model; +using Aspire.Hosting.ApplicationModel; +using Microsoft.Extensions.DependencyInjection; + +namespace Aspire.Hosting; + +/// +/// Provides extension methods for adding Dotnet Tool resources to the application model. +/// +[Experimental("ASPIREDOTNETTOOL", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] +public static class DotnetToolResourceExtensions +{ + internal const string ArgumentSeparator = "--"; + + /// + /// Adds a .NET tool resource to the application model. + /// + /// The . + /// The name of the resource. + /// The package id of the tool. + /// The . + public static IResourceBuilder AddDotnetTool(this IDistributedApplicationBuilder builder, [ResourceName] string name, string packageId) + => builder.AddDotnetTool(new DotnetToolResource(name, packageId)); + + /// + /// Adds a .NET tool resource to the distributed application model and configures it for execution via the dotnet + /// tool exec command. + /// + /// The type of the .NET tool resource to add. Must inherit from . + /// The distributed application builder to which the .NET tool resource will be added. + /// The .NET tool resource instance to add and configure. + /// The . + public static IResourceBuilder AddDotnetTool(this IDistributedApplicationBuilder builder, T resource) + where T : DotnetToolResource + { + return builder.AddResource(resource) + .WithInitialState(new CustomResourceSnapshot + { + ResourceType = KnownResourceTypes.Tool, + Properties = [] + }) + .WithIconName("Toolbox") + .WithCommand("dotnet") + .WithArgs(BuildToolExecArguments) + .OnBeforeResourceStarted(BuildToolProperties); + + void BuildToolExecArguments(CommandLineArgsCallbackContext x) + { + var toolConfig = resource.ToolConfiguration; + if (toolConfig == null) + { + // If the annotation has been removed, don't add any dotnet tool arguments. + return; + } + + x.Args.Add("tool"); + x.Args.Add("exec"); + x.Args.Add(toolConfig.PackageId); + + var sourceArg = toolConfig.IgnoreExistingFeeds ? "--source" : "--add-source"; + + foreach (var source in toolConfig.Sources) + { + x.Args.Add(sourceArg); + x.Args.Add(source); + } + + if (toolConfig.IgnoreFailedSources) + { + x.Args.Add("--ignore-failed-sources"); + } + + if (toolConfig.Version is not null) + { + x.Args.Add("--version"); + x.Args.Add(toolConfig.Version); + } + else if (toolConfig.Prerelease) + { + x.Args.Add("--prerelease"); + } + + x.Args.Add("--yes"); + x.Args.Add(ArgumentSeparator); + } + + //TODO: Move to WithConfigurationFinalizer once merged - https://github.com/dotnet/aspire/pull/13200 + async Task BuildToolProperties(T resource, BeforeResourceStartedEvent evt, CancellationToken ct) + { + var rns = evt.Services.GetRequiredService(); + var toolConfig = resource.ToolConfiguration; + if (toolConfig == null) + { + return; + } + + await rns.PublishUpdateAsync(resource, x => x with + { + Properties = [ + ..x.Properties, + new (KnownProperties.Tool.Package, toolConfig.PackageId), + new (KnownProperties.Tool.Version, toolConfig.Version), + new (KnownProperties.Resource.Source, resource.ToolConfiguration?.PackageId) + ] + }).ConfigureAwait(false); + } + } + + /// + /// Sets the package identifier for the tool configuration associated with the resource builder. + /// + /// The Dotnet Tool resource type + /// The . + /// The package identifier to assign to the tool configuration. Cannot be null. + /// The for chaining. + public static IResourceBuilder WithToolPackage(this IResourceBuilder builder, string packageId) + where T : DotnetToolResource + { + builder.Resource.ToolConfiguration?.PackageId = packageId; + return builder; + } + + /// + /// Sets the package version for a tool to use. + /// + /// The Dotnet Tool resource type + /// The . + /// The package version to use + /// The for chaining. + public static IResourceBuilder WithToolVersion(this IResourceBuilder builder, string version) + where T : DotnetToolResource + { + builder.Resource.ToolConfiguration?.Version = version; + return builder; + } + + /// + /// Allows prerelease versions of the tool to be used + /// + /// The type of resource being built. Must inherit from DotnetToolResource. + /// The . + /// The for chaining. + public static IResourceBuilder WithToolPrerelease(this IResourceBuilder builder) + where T : DotnetToolResource + { + builder.Resource.ToolConfiguration?.Prerelease = true; + return builder; + } + + /// + /// Adds a NuGet package source for tool acquisition. + /// + /// The Dotnet Tool resource type + /// The . + /// The source to add. + /// The for chaining. + public static IResourceBuilder WithToolSource(this IResourceBuilder builder, string source) + where T : DotnetToolResource + { + builder.Resource.ToolConfiguration?.Sources.Add(source); + return builder; + } + + /// + /// Configures the tool to use only the specified package sources, ignoring existing NuGet configuration. + /// + /// The Dotnet Tool resource type + /// The . + /// The for chaining. + public static IResourceBuilder WithToolIgnoreExistingFeeds(this IResourceBuilder builder) + where T : DotnetToolResource + { + builder.Resource.ToolConfiguration?.IgnoreExistingFeeds = true; + return builder; + } + + /// + /// Configures the resource to treat package source failures as warnings. + /// + /// The Dotnet Tool resource type + /// The . + /// The for chaining. + public static IResourceBuilder WithToolIgnoreFailedSources(this IResourceBuilder builder) + where T : DotnetToolResource + { + builder.Resource.ToolConfiguration?.IgnoreFailedSources = true; + return builder; + } +} diff --git a/src/Aspire.Hosting/Orchestrator/ApplicationOrchestrator.cs b/src/Aspire.Hosting/Orchestrator/ApplicationOrchestrator.cs index 6ca87b6ed39..146ca22f63b 100644 --- a/src/Aspire.Hosting/Orchestrator/ApplicationOrchestrator.cs +++ b/src/Aspire.Hosting/Orchestrator/ApplicationOrchestrator.cs @@ -172,7 +172,6 @@ private async Task OnResourceStarting(OnResourceStartingContext context) await PublishUpdateAsync(_notificationService, context.Resource, context.DcpResourceName, s => s with { State = KnownResourceStates.Starting, - ResourceType = context.ResourceType, HealthReports = GetInitialHealthReports(context.Resource) }) .ConfigureAwait(false); @@ -183,7 +182,6 @@ await PublishUpdateAsync(_notificationService, context.Resource, context.DcpReso { State = KnownResourceStates.Starting, Properties = s.Properties.SetResourceProperty(KnownProperties.Container.Image, context.Resource.TryGetContainerImageName(out var imageName) ? imageName : ""), - ResourceType = KnownResourceTypes.Container, HealthReports = GetInitialHealthReports(context.Resource) }) .ConfigureAwait(false); diff --git a/src/Shared/Model/KnownProperties.cs b/src/Shared/Model/KnownProperties.cs index 769cef8f6c0..db917b32720 100644 --- a/src/Shared/Model/KnownProperties.cs +++ b/src/Shared/Model/KnownProperties.cs @@ -60,4 +60,11 @@ public static class Parameter { public const string Value = "Value"; } + + public static class Tool + { + public const string Package = "tool.package"; + public const string Version = "tool.version"; + public const string ExecArgs = "tool.execArgs"; + } } diff --git a/src/Shared/Model/KnownResourceTypes.cs b/src/Shared/Model/KnownResourceTypes.cs index 3901f470824..dacc23be4cd 100644 --- a/src/Shared/Model/KnownResourceTypes.cs +++ b/src/Shared/Model/KnownResourceTypes.cs @@ -8,6 +8,7 @@ internal static class KnownResourceTypes public const string Executable = "Executable"; public const string ContainerExec = "ContainerExec"; public const string Project = "Project"; + public const string Tool = "Tool"; public const string Container = "Container"; public const string Parameter = "Parameter"; public const string ConnectionString = "ConnectionString"; diff --git a/tests/Aspire.Hosting.DotnetTool.Tests/AddDotnetToolTests.cs b/tests/Aspire.Hosting.DotnetTool.Tests/AddDotnetToolTests.cs new file mode 100644 index 00000000000..37f4d51d389 --- /dev/null +++ b/tests/Aspire.Hosting.DotnetTool.Tests/AddDotnetToolTests.cs @@ -0,0 +1,380 @@ +// 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; +using Aspire.Hosting.Tests.Utils; +using Aspire.Hosting.Utils; +using Microsoft.AspNetCore.InternalTesting; + +namespace Aspire.Hosting.DotnetTool.Tests; + +#pragma warning disable ASPIREDOTNETTOOL // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. +public class AddDotnetToolTests +{ + [Fact] + public void AddDotnetToolAddsResourceWithCorrectName() + { + var builder = DistributedApplication.CreateBuilder(); + var tool = builder.AddDotnetTool("mytool", "dotnet-ef"); + + Assert.Equal("mytool", tool.Resource.Name); + Assert.IsType(tool.Resource); + } + + [Fact] + public void AddDotnetToolAddsToolAnnotation() + { + var builder = DistributedApplication.CreateBuilder(); + var tool = builder.AddDotnetTool("mytool", "dotnet-ef"); + + var annotation = Assert.Single(tool.Resource.Annotations.OfType()); + Assert.Equal("dotnet-ef", annotation.PackageId); + } + + [Fact] + public void AddDotnetToolThrowsWhenPackageIdIsNull() + { + var builder = DistributedApplication.CreateBuilder(); + + Assert.Throws(() => builder.AddDotnetTool("mytool", null!)); + } + + [Fact] + public void AddDotnetToolThrowsWhenPackageIdIsEmpty() + { + var builder = DistributedApplication.CreateBuilder(); + + Assert.Throws(() => builder.AddDotnetTool("mytool", "")); + } + + [Fact] + public void AddDotnetToolThrowsWhenPackageIdIsWhitespace() + { + var builder = DistributedApplication.CreateBuilder(); + + Assert.Throws(() => builder.AddDotnetTool("mytool", " ")); + } + + [Fact] + public void AddDotnetToolSetsIconName() + { + var builder = DistributedApplication.CreateBuilder(); + var tool = builder.AddDotnetTool("mytool", "dotnet-ef"); + + var annotation = Assert.Single(tool.Resource.Annotations.OfType()); + Assert.Equal("Toolbox", annotation.IconName); + } + + [Fact] + public async Task AddDotnetToolWithDefaultSettingsGeneratesCorrectArgs() + { + var builder = DistributedApplication.CreateBuilder(); + var tool = builder.AddDotnetTool("mytool", "dotnet-ef"); + + using var app = builder.Build(); + + var args = await ArgumentEvaluator.GetArgumentListAsync(tool.Resource).DefaultTimeout(); + + Assert.Collection(args, + arg => Assert.Equal("tool", arg), + arg => Assert.Equal("exec", arg), + arg => Assert.Equal("dotnet-ef", arg), + arg => Assert.Equal("--yes", arg), + arg => Assert.Equal("--", arg) + ); + } + + [Fact] + public async Task AddDotnetToolWithVersionGeneratesCorrectArgs() + { + var builder = DistributedApplication.CreateBuilder(); + var tool = builder.AddDotnetTool("mytool", "dotnet-ef") + .WithToolVersion("10.0.0"); + + using var app = builder.Build(); + + var args = await ArgumentEvaluator.GetArgumentListAsync(tool.Resource).DefaultTimeout(); + + Assert.Collection(args, + arg => Assert.Equal("tool", arg), + arg => Assert.Equal("exec", arg), + arg => Assert.Equal("dotnet-ef", arg), + arg => Assert.Equal("--version", arg), + arg => Assert.Equal("10.0.0", arg), + arg => Assert.Equal("--yes", arg), + arg => Assert.Equal("--", arg) + ); + } + + [Fact] + public async Task AddDotnetToolWithPrereleaseGeneratesCorrectArgs() + { + var builder = DistributedApplication.CreateBuilder(); + var tool = builder.AddDotnetTool("mytool", "dotnet-ef") + .WithToolPrerelease(); + + using var app = builder.Build(); + + var args = await ArgumentEvaluator.GetArgumentListAsync(tool.Resource).DefaultTimeout(); + + Assert.Collection(args, + arg => Assert.Equal("tool", arg), + arg => Assert.Equal("exec", arg), + arg => Assert.Equal("dotnet-ef", arg), + arg => Assert.Equal("--prerelease", arg), + arg => Assert.Equal("--yes", arg), + arg => Assert.Equal("--", arg) + ); + } + + [Fact] + public async Task AddDotnetToolWithCustomSourceGeneratesCorrectArgs() + { + var builder = DistributedApplication.CreateBuilder(); + var tool = builder.AddDotnetTool("mytool", "dotnet-ef") + .WithToolSource("https://custom.nuget.org/v3/index.json"); + + using var app = builder.Build(); + + var args = await ArgumentEvaluator.GetArgumentListAsync(tool.Resource).DefaultTimeout(); + + Assert.Collection(args, + arg => Assert.Equal("tool", arg), + arg => Assert.Equal("exec", arg), + arg => Assert.Equal("dotnet-ef", arg), + arg => Assert.Equal("--add-source", arg), + arg => Assert.Equal("https://custom.nuget.org/v3/index.json", arg), + arg => Assert.Equal("--yes", arg), + arg => Assert.Equal("--", arg) + ); + } + + [Fact] + public async Task AddDotnetToolWithMultipleSourcesGeneratesCorrectArgs() + { + var builder = DistributedApplication.CreateBuilder(); + var tool = builder.AddDotnetTool("mytool", "dotnet-ef") + .WithToolSource("https://source1.nuget.org/v3/index.json") + .WithToolSource("https://source2.nuget.org/v3/index.json"); + + using var app = builder.Build(); + + var args = await ArgumentEvaluator.GetArgumentListAsync(tool.Resource).DefaultTimeout(); + + Assert.Collection(args, + arg => Assert.Equal("tool", arg), + arg => Assert.Equal("exec", arg), + arg => Assert.Equal("dotnet-ef", arg), + arg => Assert.Equal("--add-source", arg), + arg => Assert.Equal("https://source1.nuget.org/v3/index.json", arg), + arg => Assert.Equal("--add-source", arg), + arg => Assert.Equal("https://source2.nuget.org/v3/index.json", arg), + arg => Assert.Equal("--yes", arg), + arg => Assert.Equal("--", arg) + ); + } + + [Fact] + public async Task AddDotnetToolWithIgnoreExistingFeedsUsesSourceInsteadOfAddSource() + { + var builder = DistributedApplication.CreateBuilder(); + var tool = builder.AddDotnetTool("mytool", "dotnet-ef") + .WithToolSource("https://custom.nuget.org/v3/index.json") + .WithToolIgnoreExistingFeeds(); + + using var app = builder.Build(); + + var args = await ArgumentEvaluator.GetArgumentListAsync(tool.Resource).DefaultTimeout(); + + Assert.Collection(args, + arg => Assert.Equal("tool", arg), + arg => Assert.Equal("exec", arg), + arg => Assert.Equal("dotnet-ef", arg), + arg => Assert.Equal("--source", arg), + arg => Assert.Equal("https://custom.nuget.org/v3/index.json", arg), + arg => Assert.Equal("--yes", arg), + arg => Assert.Equal("--", arg) + ); + } + + [Fact] + public async Task AddDotnetToolWithIgnoreFailedSourcesGeneratesCorrectArgs() + { + var builder = DistributedApplication.CreateBuilder(); + var tool = builder.AddDotnetTool("mytool", "dotnet-ef") + .WithToolIgnoreFailedSources(); + + using var app = builder.Build(); + + var args = await ArgumentEvaluator.GetArgumentListAsync(tool.Resource).DefaultTimeout(); + + Assert.Collection(args, + arg => Assert.Equal("tool", arg), + arg => Assert.Equal("exec", arg), + arg => Assert.Equal("dotnet-ef", arg), + arg => Assert.Equal("--ignore-failed-sources", arg), + arg => Assert.Equal("--yes", arg), + arg => Assert.Equal("--", arg) + ); + } + + [Fact] + public async Task AddDotnetToolWithAdditionalArgsPassedThrough() + { + var builder = DistributedApplication.CreateBuilder(); + var tool = builder.AddDotnetTool("mytool", "dotnet-ef") + .WithArgs("database", "update"); + + using var app = builder.Build(); + + var args = await ArgumentEvaluator.GetArgumentListAsync(tool.Resource).DefaultTimeout(); + + Assert.Collection(args, + arg => Assert.Equal("tool", arg), + arg => Assert.Equal("exec", arg), + arg => Assert.Equal("dotnet-ef", arg), + arg => Assert.Equal("--yes", arg), + arg => Assert.Equal("--", arg), + arg => Assert.Equal("database", arg), + arg => Assert.Equal("update", arg) + ); + } + + [Fact] + public async Task AddDotnetToolWithComplexConfigurationGeneratesCorrectArgs() + { + var builder = DistributedApplication.CreateBuilder(); + var tool = builder.AddDotnetTool("mytool", "dotnet-ef") + .WithToolVersion("9.0.1") + .WithToolSource("https://custom.nuget.org/v3/index.json") + .WithToolIgnoreFailedSources() + .WithArgs("database", "update"); + + using var app = builder.Build(); + + var args = await ArgumentEvaluator.GetArgumentListAsync(tool.Resource).DefaultTimeout(); + + Assert.Collection(args, + arg => Assert.Equal("tool", arg), + arg => Assert.Equal("exec", arg), + arg => Assert.Equal("dotnet-ef", arg), + arg => Assert.Equal("--add-source", arg), + arg => Assert.Equal("https://custom.nuget.org/v3/index.json", arg), + arg => Assert.Equal("--ignore-failed-sources", arg), + arg => Assert.Equal("--version", arg), + arg => Assert.Equal("9.0.1", arg), + arg => Assert.Equal("--yes", arg), + arg => Assert.Equal("--", arg), + arg => Assert.Equal("database", arg), + arg => Assert.Equal("update", arg) + ); + } + + [Fact] + public void WithPackageVersionSetsVersionInAnnotation() + { + var builder = DistributedApplication.CreateBuilder(); + var tool = builder.AddDotnetTool("mytool", "dotnet-ef") + .WithToolVersion("10.0.*"); + + var annotation = tool.Resource.Annotations.OfType().Single(); + Assert.Equal("10.0.*", annotation.Version); + } + + [Fact] + public void WithPackagePrereleaseSetsPreReleaseInAnnotation() + { + var builder = DistributedApplication.CreateBuilder(); + var tool = builder.AddDotnetTool("mytool", "dotnet-ef") + .WithToolPrerelease(); + + var annotation = tool.Resource.Annotations.OfType().Single(); + Assert.True(annotation.Prerelease); + } + + [Fact] + public void WithPackageSourceAddsSourceToAnnotation() + { + var builder = DistributedApplication.CreateBuilder(); + var tool = builder.AddDotnetTool("mytool", "dotnet-ef") + .WithToolSource("https://custom.nuget.org/v3/index.json"); + + var annotation = tool.Resource.Annotations.OfType().Single(); + Assert.Single(annotation.Sources); + Assert.Equal("https://custom.nuget.org/v3/index.json", annotation.Sources[0]); + } + + [Fact] + public void WithPackageIgnoreExistingFeedsSetsIgnoreExistingFeedsInAnnotation() + { + var builder = DistributedApplication.CreateBuilder(); + var tool = builder.AddDotnetTool("mytool", "dotnet-ef") + .WithToolIgnoreExistingFeeds(); + + var annotation = tool.Resource.Annotations.OfType().Single(); + Assert.True(annotation.IgnoreExistingFeeds); + } + + [Fact] + public void WithPackageIgnoreFailedSourcesSetsIgnoreFailedSourcesInAnnotation() + { + var builder = DistributedApplication.CreateBuilder(); + var tool = builder.AddDotnetTool("mytool", "dotnet-ef") + .WithToolIgnoreFailedSources(); + + var annotation = tool.Resource.Annotations.OfType().Single(); + Assert.True(annotation.IgnoreFailedSources); + } + + [Fact] + public async Task RemovingToolAnnotationResultsInNoArgs() + { + var builder = DistributedApplication.CreateBuilder(); + var tool = builder.AddDotnetTool("mytool", "dotnet-ef"); + + // Remove the annotation to simulate it being removed + var annotation = tool.Resource.Annotations.OfType().Single(); + tool.Resource.Annotations.Remove(annotation); + + using var app = builder.Build(); + + var args = await ArgumentEvaluator.GetArgumentListAsync(tool.Resource).DefaultTimeout(); + + // Should be empty since annotation was removed + Assert.Empty(args); + } + + [Fact] + public async Task AddDotnetToolGeneratesCorrectManifest() + { + var builder = DistributedApplication.CreateBuilder(); + var tool = builder.AddDotnetTool("ef-tool", "dotnet-ef") + .WithToolVersion("10.0.0") + .WithArgs("--version"); + + using var app = builder.Build(); + + var manifest = await ManifestUtils.GetManifest(tool.Resource).DefaultTimeout(); + + var expectedManifest = + """ + { + "type": "executable.v0", + "workingDirectory": ".", + "command": "dotnet", + "args": [ + "tool", + "exec", + "dotnet-ef", + "--version", + "10.0.0", + "--yes", + "--", + "--version" + ] + } + """; + + Assert.Equal(expectedManifest, manifest.ToString()); + } +} diff --git a/tests/Aspire.Hosting.DotnetTool.Tests/Aspire.Hosting.DotnetTool.Tests.csproj b/tests/Aspire.Hosting.DotnetTool.Tests/Aspire.Hosting.DotnetTool.Tests.csproj new file mode 100644 index 00000000000..64fcee94e42 --- /dev/null +++ b/tests/Aspire.Hosting.DotnetTool.Tests/Aspire.Hosting.DotnetTool.Tests.csproj @@ -0,0 +1,17 @@ + + + + $(DefaultTargetFramework) + + + + + + + + + + + + + diff --git a/tests/Aspire.Hosting.DotnetTool.Tests/DotnetToolFunctionalTests.cs.cs b/tests/Aspire.Hosting.DotnetTool.Tests/DotnetToolFunctionalTests.cs.cs new file mode 100644 index 00000000000..a56a28ab937 --- /dev/null +++ b/tests/Aspire.Hosting.DotnetTool.Tests/DotnetToolFunctionalTests.cs.cs @@ -0,0 +1,54 @@ +// 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; +using Aspire.Hosting.Utils; + +namespace Aspire.Hosting.DotnetTool.Tests; + +public class DotnetToolFunctionalTests(ITestOutputHelper testOutputHelper) +{ + [Fact] + public async Task VerifyDotnetToolResource() + { + var cts = new CancellationTokenSource(TimeSpan.FromMinutes(2)); + using var builder = TestDistributedApplicationBuilder.Create().WithTestAndResourceLogging(testOutputHelper); + var resource = builder + .AddDotnetTool("tool", "dotnet-ef") + .WithArgs("--help"); + + using var app = builder.Build(); + await app.StartAsync(cts.Token); + + await app.ResourceNotifications.WaitForResourceAsync(resource.Resource.Name, KnownResourceStates.Running, cts.Token); + var terminalState = await app.ResourceNotifications.WaitForResourceAsync(resource.Resource.Name, KnownResourceStates.TerminalStates, cts.Token); + + Assert.Equal(KnownResourceStates.Finished, terminalState); + + Assert.True(app.ResourceNotifications.TryGetCurrentState(resource.Resource.Name, out var resourceState)); + Assert.Equal(resourceState.Snapshot.ExitCode, 0); + } + + [Fact] + public async Task VerifyNonExistentDotnetToolResource() + { + var cts = new CancellationTokenSource(TimeSpan.FromMinutes(2)); + using var builder = TestDistributedApplicationBuilder.Create().WithTestAndResourceLogging(testOutputHelper); + var resource = builder + .AddDotnetTool("tool", "dotnet-ef") + .WithArgs("--help") + .WithToolSource("./fake-package-feed") + .WithToolIgnoreExistingFeeds(); + + using var app = builder.Build(); + await app.StartAsync(cts.Token); + + await app.ResourceNotifications.WaitForResourceAsync(resource.Resource.Name, KnownResourceStates.Running, cts.Token); + var terminalState = await app.ResourceNotifications.WaitForResourceAsync(resource.Resource.Name, KnownResourceStates.TerminalStates, cts.Token); + Assert.Equal(KnownResourceStates.Finished, terminalState); + + Assert.True(app.ResourceNotifications.TryGetCurrentState(resource.Resource.Name, out var resourceState)); + Assert.NotEqual(resourceState.Snapshot.ExitCode, 0); + } + +} diff --git a/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs b/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs index c158e31d53b..838e22d2aa0 100644 --- a/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs +++ b/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs @@ -165,6 +165,42 @@ public async Task CreateExecutable_LaunchProfileHasCommandLineArgs_AnnotationsAd Assert.Equal(expectedAnnotations, argAnnotations.Select(a => a.Argument)); } + [Theory] + [InlineData()] + [InlineData("--arg1", "foo")] + public async Task CreateExecutable_ToolHasCommandLineArgs_AnnotationsAdded(params string[] toolArgs) + { + var builder = DistributedApplication.CreateBuilder(new DistributedApplicationOptions + { + AssemblyName = typeof(DistributedApplicationTests).Assembly.FullName + }); + + var resourceBuilder = builder.AddDotnetTool("tool", "package") + .WithArgs(toolArgs); + + var kubernetesService = new TestKubernetesService(); + using var app = builder.Build(); + var distributedAppModel = app.Services.GetRequiredService(); + var dcpOptions = new DcpOptions { DashboardPath = "./dashboard", ResourceNameSuffix = "suffix" }; + + var events = new DcpExecutorEvents(); + var resourceNotificationService = ResourceNotificationServiceTestHelpers.Create(); + + var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService, dcpOptions: dcpOptions, events: events); + await appExecutor.RunApplicationAsync(); + + var executables = kubernetesService.CreatedResources.OfType().ToList(); + var exe = Assert.Single(executables); + + string[] dotnetToolExecArgs = ["tool", "exec", "package", "--yes", "--"]; + string[] callArgs = [..dotnetToolExecArgs, ..toolArgs]; + + Assert.Equal(callArgs, exe.Spec.Args); + + Assert.True(exe.TryGetAnnotationAsObjectList(CustomResource.ResourceAppArgsAnnotation, out var argAnnotations)); + Assert.Equal(toolArgs, argAnnotations.Select(a => a.Argument)); + } + [Fact] public async Task ResourceRestarted_EnvironmentCallbacksApplied() {