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()
{