diff --git a/Aspire.slnx b/Aspire.slnx
index 2bcb3d8dc11..cc3f5853484 100644
--- a/Aspire.slnx
+++ b/Aspire.slnx
@@ -357,6 +357,10 @@
+
+
+
+
diff --git a/playground/ResourceSubstitution/.vscode/launch.json b/playground/ResourceSubstitution/.vscode/launch.json
new file mode 100644
index 00000000000..2ba667c9c2f
--- /dev/null
+++ b/playground/ResourceSubstitution/.vscode/launch.json
@@ -0,0 +1,11 @@
+{
+ "version": "0.2.0",
+ "configurations": [
+ {
+ "name": "Run AppHost",
+ "type": "aspire",
+ "request": "launch",
+ "program": "${workspaceFolder}"
+ }
+ ]
+}
diff --git a/playground/ResourceSubstitution/ResourceSubstitution.Api/Program.cs b/playground/ResourceSubstitution/ResourceSubstitution.Api/Program.cs
new file mode 100644
index 00000000000..d36d21fe5bd
--- /dev/null
+++ b/playground/ResourceSubstitution/ResourceSubstitution.Api/Program.cs
@@ -0,0 +1,15 @@
+using System.Runtime.InteropServices;
+
+var builder = WebApplication.CreateBuilder(args);
+builder.AddServiceDefaults();
+
+var app = builder.Build();
+
+app.MapGet("/", () => $"""
+ 👋🌍
+ 🏷️ Host: {Environment.MachineName}
+ 💻 OS: { RuntimeInformation.OSDescription }
+ 🪪 PID: {Environment.ProcessId}
+ """);
+
+app.Run();
\ No newline at end of file
diff --git a/playground/ResourceSubstitution/ResourceSubstitution.Api/Properties/launchSettings.json b/playground/ResourceSubstitution/ResourceSubstitution.Api/Properties/launchSettings.json
new file mode 100644
index 00000000000..a16ae47d2d1
--- /dev/null
+++ b/playground/ResourceSubstitution/ResourceSubstitution.Api/Properties/launchSettings.json
@@ -0,0 +1,25 @@
+{
+ "$schema": "http://json.schemastore.org/launchsettings.json",
+ "profiles": {
+ "https": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": true,
+ "launchUrl": "",
+ "applicationUrl": "https://localhost:5450;http://localhost:5451",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ },
+ "http": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": true,
+ "launchUrl": "",
+ "applicationUrl": "http://localhost:5451",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ }
+ }
+}
diff --git a/playground/ResourceSubstitution/ResourceSubstitution.Api/ResourceSubstitution.Api.csproj b/playground/ResourceSubstitution/ResourceSubstitution.Api/ResourceSubstitution.Api.csproj
new file mode 100644
index 00000000000..3b820cba91a
--- /dev/null
+++ b/playground/ResourceSubstitution/ResourceSubstitution.Api/ResourceSubstitution.Api.csproj
@@ -0,0 +1,13 @@
+
+
+
+ $(DefaultTargetFramework)
+ enable
+ enable
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/playground/ResourceSubstitution/ResourceSubstitution.AppHost/AppHost.cs b/playground/ResourceSubstitution/ResourceSubstitution.AppHost/AppHost.cs
new file mode 100644
index 00000000000..c7c4194fcea
--- /dev/null
+++ b/playground/ResourceSubstitution/ResourceSubstitution.AppHost/AppHost.cs
@@ -0,0 +1,51 @@
+#pragma warning disable ASPIRECSHARPAPPS001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
+#pragma warning disable ASPIRECERTIFICATES001 // 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 projectPath = new Projects.ResourceSubstitution_Api().ProjectPath;
+
+// Normal project resource
+// Overriding ports from launch settings to avoid conflicting with the substituted resource
+builder.AddCSharpApp("project", projectPath)
+ .WithEndpoint("http", x => x.Port = null)
+ .WithEndpoint("https", x => x.Port = null)
+ .WithHttpHealthCheck();
+
+//A regular container resource
+builder.AddContainer("container", "aspire/resourcesubstitution.apphost/container-from-project")
+ .WithImageTag("aspire-image-build")
+ .WithHttpEndpoint(targetPort: 8080, env: "ASPNETCORE_HTTP_PORTS")
+ .WithHttpsEndpoint(targetPort: 8443, env: "ASPNETCORE_HTTPS_PORTS")
+ .WithHttpHealthCheck()
+ .WithHttpsCertificateConfiguration(ctx =>
+ {
+ ctx.EnvironmentVariables["Kestrel__Certificates__Default__Path"] = ctx.CertificatePath;
+ ctx.EnvironmentVariables["Kestrel__Certificates__Default__KeyPath"] = ctx.KeyPath;
+ if (ctx.Password is not null)
+ {
+ ctx.EnvironmentVariables["Kestrel__Certificates__Default__Password"] = ctx.Password;
+ }
+
+ return Task.CompletedTask;
+ });
+
+// A container resource, we change to running a project
+// Including picking up ports from launch settings
+builder.AddContainer("project-from-container", "doesnt-matter")
+ .RunAsProject(projectPath)
+ .WithHttpHealthCheck();
+
+// A project resource that we run as a container
+// Overriding ports as the launch settings port so as to not conflict with `project-from-container`
+builder.AddProject("container-from-project")
+ .WithEndpoint("http", x => x.Port = null)
+ .WithEndpoint("https", x => x.Port = null)
+ .WithHttpHealthCheck()
+ .RunAsContainer();
+
+// A project resource that we run as a .NET tool
+builder.AddProject("tool-from-project")
+ .RunAsTool();
+
+builder.Build().Run();
\ No newline at end of file
diff --git a/playground/ResourceSubstitution/ResourceSubstitution.AppHost/Extensions.cs b/playground/ResourceSubstitution/ResourceSubstitution.AppHost/Extensions.cs
new file mode 100644
index 00000000000..5c23fc6a71b
--- /dev/null
+++ b/playground/ResourceSubstitution/ResourceSubstitution.AppHost/Extensions.cs
@@ -0,0 +1,295 @@
+#pragma warning disable ASPIRECSHARPAPPS001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
+#pragma warning disable ASPIRECERTIFICATES001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
+#pragma warning disable ASPIRECONTAINERRUNTIME001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
+#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.
+
+using Aspire.Dashboard.Model;
+using Aspire.Hosting.Publishing;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+
+public static class Extensions
+{
+ public static IResourceBuilder RunAsContainer(this IResourceBuilder builder)
+ where T : ProjectResource
+ {
+ if (!builder.ApplicationBuilder.ExecutionContext.IsRunMode)
+ {
+ return builder;
+ }
+
+ var imagePublisher = AddContainerPublisher();
+
+ TransmuteResourceAnnotations();
+ FixEndpoints();
+
+ return builder
+ .WaitForCompletion(imagePublisher)
+ .WithDotnetContainerDefaults()
+ .WithInitialState(new CustomResourceSnapshot { ResourceType = KnownResourceTypes.Container, Properties = [] });
+
+ void TransmuteResourceAnnotations()
+ {
+ if (!builder.Resource.TryGetLastAnnotation(out var projectMetadata))
+ {
+ throw new InvalidOperationException("RunAsContainer can only be used on resources with project metadata.");
+ }
+ builder.Resource.Annotations.Remove(projectMetadata);
+
+ if (builder.Resource.TryGetLastAnnotation(out var executableAnnotation))
+ {
+ builder.Resource.Annotations.Remove(executableAnnotation);
+ }
+
+ var appHostName = builder.ApplicationBuilder.AppHostAssembly!.GetName().Name!.ToLowerInvariant();
+ builder.WithAnnotation(new ContainerImageAnnotation
+ {
+ Image = $"aspire/{appHostName}/{builder.Resource.Name}",
+ Tag = "aspire-image-build",
+ Registry = "" // Use local registry
+ }, ResourceAnnotationMutationBehavior.Replace);
+ }
+
+ // As a project, the target port is left null.
+ // For executables, DCP will allocate it's own port if the target port is null
+ // This does not happen for containers, so we give those endpoints explicit ports
+ void FixEndpoints()
+ {
+ //TODO: logic isn't complete - this doesn't currently consider Kestrel endpoint configuration.
+ var http = builder.GetEndpoint("http");
+ if (http.Exists && http.EndpointAnnotation.TargetPort is null)
+ {
+ http.EndpointAnnotation.TargetPort = 8000;
+ builder.WithEnvironment("ASPNETCORE_HTTP_PORTS", http.Property(EndpointProperty.TargetPort));
+ }
+
+ var https = builder.GetEndpoint("https");
+ if (https.Exists && https.EndpointAnnotation.TargetPort is null)
+ {
+ https.EndpointAnnotation.TargetPort = 8443;
+ builder.WithEnvironment("ASPNETCORE_HTTPS_PORTS", https.Property(EndpointProperty.TargetPort));
+ }
+
+ // `ASPNETCORE_URLS` typically has `localhost` as the host
+ // But for containers, we need to bind to all interfaces so the tunnel can access it
+ builder.WithEnvironment(ctx => ctx.EnvironmentVariables.Remove("ASPNETCORE_URLS"));
+ }
+
+ // This could potentially use `IResourceContainerImageManager` instead, but this mirrors
+ // the tool publishing approach, and is easier to troubleshoot errors in run mode.
+ IResourceBuilder AddContainerPublisher()
+ {
+ return builder.ApplicationBuilder.AddExecutable($"{builder.Resource.Name}-publisher", "dotnet", ".")
+ .WithArgs("publish", builder.Resource.GetProjectMetadata().ProjectPath, "/t:PublishContainer")
+ .WithIconName("BoxToolbox")
+ .WithParentRelationship(builder)
+ .WaitForContainerRuntime()
+ .WithArgs(ctx =>
+ {
+ // Lazy set the image metadata in case someone mutates the image annotation
+ if (builder.Resource.TryGetLastAnnotation(out var imageAnnotation))
+ {
+ ctx.Args.Add($"/p:ContainerRepository=\"{imageAnnotation.Image}\"");
+ ctx.Args.Add($"/p:ContainerImageTags=\"{imageAnnotation.Tag}\"");
+ ctx.Args.Add($"/p:ContainerRegistry=\"{imageAnnotation.Registry}\"");
+ }
+ });
+ }
+ }
+
+ public static IResourceBuilder RunAsProject(this IResourceBuilder builder, string projectPath)
+ where T : ContainerResource
+ {
+ if (!builder.ApplicationBuilder.ExecutionContext.IsRunMode)
+ {
+ return builder;
+ }
+
+ TransmuteAnnotations();
+ FixEndpoints();
+ return builder;
+
+ void TransmuteAnnotations()
+ {
+ if (builder.Resource.TryGetLastAnnotation(out var containerAnnotation))
+ {
+ builder.Resource.Annotations.Remove(containerAnnotation);
+ }
+ if (builder.Resource.TryGetLastAnnotation(out var executableAnnotation))
+ {
+ builder.Resource.Annotations.Remove(executableAnnotation);
+ }
+ if (builder.Resource.TryGetLastAnnotation(out var dotnetToolAnnotation))
+ {
+ builder.Resource.Annotations.Remove(dotnetToolAnnotation);
+ }
+
+ // For now, create a dummy csharp app resource, then copy it's annotations to our new resource
+ //
+ // Exposing ProjectResourceBuilderExtensions.WithProjectDefaults may be a cleaner approach in the long run
+ // And making it usable on any `IResource`
+ var newProject = builder.ApplicationBuilder.AddCSharpApp($"temp-{Guid.NewGuid()}", projectPath);
+ builder.ApplicationBuilder.Resources.Remove(newProject.Resource);
+
+ // TODO: A clever merge approach may be needed here
+ foreach (var annotation in newProject.Resource.Annotations)
+ {
+ builder.Resource.Annotations.Add(annotation);
+ }
+ }
+
+ void FixEndpoints()
+ {
+ // The endpoint references on the temp project resource have a reference back to the temp resource
+ // Which will never become available.
+ // If using `WithProjectDefaults`, this should no longer not be necessary
+ builder.WithEnvironment(ctx =>
+ {
+ ctx.EnvironmentVariables.Remove("ASPNETCORE_URLS");
+
+ foreach (var endpointName in new[] { "http", "https" })
+ {
+ var endpoint = builder.GetEndpoint(endpointName);
+ if (endpoint.Exists)
+ {
+ ctx.EnvironmentVariables[$"ASPNETCORE_{endpointName.ToUpperInvariant()}_PORTS"] = endpoint.Property(EndpointProperty.TargetPort);
+ }
+ }
+ });
+ }
+ }
+
+ // This is a slightly silly example - not sure why you'd ever want to run a project as a tool
+ // But it helps to prove the model out.
+ public static IResourceBuilder RunAsTool(this IResourceBuilder builder)
+ where T : ProjectResource
+ {
+ if (!builder.ApplicationBuilder.ExecutionContext.IsRunMode)
+ {
+ return builder;
+ }
+
+ var toolPublisher = AddToolPublisher();
+
+ TransmuteResource();
+
+ return builder
+ .WaitForCompletion(toolPublisher);
+
+ void TransmuteResource()
+ {
+ if (builder.Resource.TryGetLastAnnotation(out var projectMetadataAnnotation))
+ {
+ builder.Resource.Annotations.Remove(projectMetadataAnnotation);
+ }
+ if (builder.Resource.TryGetLastAnnotation(out var containerImageAnnotation))
+ {
+ builder.Resource.Annotations.Remove(containerImageAnnotation);
+ }
+
+ // again, rather than copy
+ var newTool = builder.ApplicationBuilder.AddDotnetTool($"temp-{Guid.NewGuid()}", builder.Resource.Name)
+ .WithToolIgnoreExistingFeeds()
+ .WithToolPrerelease();
+
+ builder.ApplicationBuilder.Resources.Remove(newTool.Resource);
+
+ foreach (var annotation in newTool.Resource.Annotations)
+ {
+ builder.Resource.Annotations.Add(annotation);
+ }
+
+ builder.OnBeforeResourceStarted((resource, evt, ct) =>
+ {
+ var outputPath = GetToolPackageOutputPath(evt.Services);
+ newTool.WithToolSource(outputPath);
+ return Task.CompletedTask;
+ });
+ }
+
+ IResourceBuilder AddToolPublisher()
+ {
+ var projectPath = builder.Resource.GetProjectMetadata().ProjectPath;
+ return builder.ApplicationBuilder.AddExecutable($"{builder.Resource.Name}-tool-publisher", "dotnet", ".")
+ .WithArgs(ctx =>
+ {
+ ctx.Args.Add("pack");
+ ctx.Args.Add(projectPath);
+ ctx.Args.Add("--no-build");
+ ctx.Args.Add("-p:IsPackable=true");
+ ctx.Args.Add("-p:PackAsTool=true");
+ ctx.Args.Add($"-p:PackageId=\"{builder.Resource.Name}\"");
+ ctx.Args.Add("--output");
+ ctx.Args.Add(GetToolPackageOutputPath(ctx.ExecutionContext.ServiceProvider));
+ })
+ .WithIconName("BoxToolbox")
+ .WithParentRelationship(builder);
+ }
+
+ static string GetToolPackageOutputPath(IServiceProvider services)
+ {
+ var aspireStore = services.GetRequiredService();
+
+ var toolPackageOutputPath = Path.Combine(aspireStore.BasePath, "tools");
+ Directory.CreateDirectory(toolPackageOutputPath);
+
+ return toolPackageOutputPath;
+ }
+ }
+
+ private static IResourceBuilder WithDotnetContainerDefaults(this IResourceBuilder builder)
+ where T : IResourceWithEnvironment, IResourceWithArgs
+ {
+ return builder
+ .WithDeveloperCertificateTrust(true)
+ .WithHttpsDeveloperCertificate()
+ .WithHttpsCertificateConfiguration(ctx =>
+ {
+ ctx.EnvironmentVariables["Kestrel__Certificates__Default__Path"] = ctx.CertificatePath;
+ ctx.EnvironmentVariables["Kestrel__Certificates__Default__KeyPath"] = ctx.KeyPath;
+ if (ctx.Password is not null)
+ {
+ ctx.EnvironmentVariables["Kestrel__Certificates__Default__Password"] = ctx.Password;
+ }
+
+ return Task.CompletedTask;
+ });
+ }
+
+ private static IResourceBuilder WaitForContainerRuntime(this IResourceBuilder builder)
+ where T : IResource
+ {
+ return builder.OnBeforeResourceStarted(async (resource, evt, ct) =>
+ {
+ var runtimeResolver = evt.Services.GetRequiredService();
+
+ var runtime = await runtimeResolver.ResolveAsync(ct);
+
+ var isRunning = await runtime.CheckIfRunningAsync(ct);
+
+ if (isRunning)
+ {
+ return;
+ }
+
+ ResourceStateSnapshot? beforeWaitState = null;
+ var rns = evt.Services.GetRequiredService();
+ await rns.PublishUpdateAsync(resource, x =>
+ {
+ beforeWaitState = x.State;
+ return x with { State = KnownResourceStates.RuntimeUnhealthy };
+ });
+
+ var logger = evt.Services.GetRequiredService().GetLogger(resource);
+ logger.LogInformation("Waiting for container runtime {RuntimeName} to be available...", runtime.Name);
+
+ while (!isRunning)
+ {
+ await Task.Delay(TimeSpan.FromSeconds(1), ct);
+ isRunning = await runtime.CheckIfRunningAsync(ct);
+ }
+
+ await rns.PublishUpdateAsync(resource, x => x with { State = beforeWaitState });
+ });
+ }
+}
\ No newline at end of file
diff --git a/playground/ResourceSubstitution/ResourceSubstitution.AppHost/Properties/launchSettings.json b/playground/ResourceSubstitution/ResourceSubstitution.AppHost/Properties/launchSettings.json
new file mode 100644
index 00000000000..7ddadf3da6c
--- /dev/null
+++ b/playground/ResourceSubstitution/ResourceSubstitution.AppHost/Properties/launchSettings.json
@@ -0,0 +1,39 @@
+{
+ "$schema": "https://json.schemastore.org/launchsettings.json",
+ "profiles": {
+ "https": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": true,
+ "applicationUrl": "https://substitution.dev.localhost:15406;http://substitution.dev.localhost:15407",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development",
+ "DOTNET_ENVIRONMENT": "Development",
+ "ASPIRE_SHOW_DASHBOARD_RESOURCES": "true"
+ }
+ },
+ "http": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": true,
+ "applicationUrl": "http://substitution.dev.localhost:15407",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development",
+ "DOTNET_ENVIRONMENT": "Development",
+ "ASPIRE_SHOW_DASHBOARD_RESOURCES": "true",
+ "ASPIRE_ALLOW_UNSECURED_TRANSPORT": "true"
+ }
+ },
+ "generate-manifest": {
+ "commandName": "Project",
+ "launchBrowser": true,
+ "dotnetRunMessages": true,
+ "commandLineArgs": "--publisher manifest --output-path aspire-manifest.json",
+ "applicationUrl": "http://substitution.dev.localhost:15407",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development",
+ "DOTNET_ENVIRONMENT": "Development"
+ }
+ }
+ }
+}
diff --git a/playground/ResourceSubstitution/ResourceSubstitution.AppHost/ResourceSubstitution.AppHost.csproj b/playground/ResourceSubstitution/ResourceSubstitution.AppHost/ResourceSubstitution.AppHost.csproj
new file mode 100644
index 00000000000..818590fbb73
--- /dev/null
+++ b/playground/ResourceSubstitution/ResourceSubstitution.AppHost/ResourceSubstitution.AppHost.csproj
@@ -0,0 +1,23 @@
+
+
+
+ Exe
+ $(DefaultTargetFramework)
+ enable
+ enable
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/playground/ResourceSubstitution/aspire.config.json b/playground/ResourceSubstitution/aspire.config.json
new file mode 100644
index 00000000000..255dc93a756
--- /dev/null
+++ b/playground/ResourceSubstitution/aspire.config.json
@@ -0,0 +1,5 @@
+{
+ "appHost": {
+ "path": "ResourceSubstitution.AppHost/ResourceSubstitution.AppHost.csproj"
+ }
+}
\ No newline at end of file
diff --git a/src/Aspire.Hosting/ApplicationModel/CommandsConfigurationExtensions.cs b/src/Aspire.Hosting/ApplicationModel/CommandsConfigurationExtensions.cs
index 26b76f7eae2..25bc2996f18 100644
--- a/src/Aspire.Hosting/ApplicationModel/CommandsConfigurationExtensions.cs
+++ b/src/Aspire.Hosting/ApplicationModel/CommandsConfigurationExtensions.cs
@@ -88,7 +88,7 @@ internal static void AddLifeCycleCommands(this IResource resource)
// Use a more detailed description for .NET projects to help AI understand
// that source code changes won't take effect until rebuilding the project.
- var restartDescription = resource is ProjectResource
+ var restartDescription = resource.TryGetProjectAnnotation(out _)
? CommandStrings.RestartProjectDescription
: CommandStrings.RestartDescription;
@@ -122,13 +122,9 @@ internal static void AddLifeCycleCommands(this IResource resource)
iconVariant: IconVariant.Regular,
isHighlighted: false));
- if (resource is ProjectResource projectResource)
+ if (resource.TryGetProjectAnnotation(out var projectMetadata) && !projectMetadata.IsFileBasedApp)
{
- var projectMetadata = projectResource.Annotations.OfType().SingleOrDefault();
- if (projectMetadata is null || !projectMetadata.IsFileBasedApp)
- {
- AddRebuildCommand(projectResource);
- }
+ AddRebuildCommand(resource);
}
// Treat "Unknown" as stopped so the command to start the resource is available when "Unknown".
@@ -142,7 +138,7 @@ internal static void AddLifeCycleCommands(this IResource resource)
static bool HasNoState(string? state) => string.IsNullOrEmpty(state);
}
- private static void AddRebuildCommand(ProjectResource projectResource)
+ private static void AddRebuildCommand(IResource projectResource)
{
// When a resource has replicas, the command framework invokes the handler
// once per replica in parallel. We use a shared task so only a single build
@@ -191,7 +187,7 @@ async Task ExecuteRebuildAndResetAsync(ExecuteCommandConte
}
}
- private static async Task ExecuteRebuildAsync(ExecuteCommandContext context, ProjectResource projectResource)
+ private static async Task ExecuteRebuildAsync(ExecuteCommandContext context, IResource projectResource)
{
var orchestrator = context.ServiceProvider.GetRequiredService();
var resourceNotificationService = context.ServiceProvider.GetRequiredService();
diff --git a/src/Aspire.Hosting/ApplicationModel/ProjectResource.cs b/src/Aspire.Hosting/ApplicationModel/ProjectResource.cs
index 90b141f06b9..fd758408b12 100644
--- a/src/Aspire.Hosting/ApplicationModel/ProjectResource.cs
+++ b/src/Aspire.Hosting/ApplicationModel/ProjectResource.cs
@@ -293,7 +293,7 @@ private static async Task GetContainerWorkingDirectoryAsync(string proje
private string DebuggerToString()
{
var path = "";
- if (this.TryGetLastAnnotation(out var metadata))
+ if (this.TryGetProjectAnnotation(out var metadata))
{
path = metadata.ProjectPath;
}
diff --git a/src/Aspire.Hosting/ApplicationModel/ProjectResourceExtensions.cs b/src/Aspire.Hosting/ApplicationModel/ProjectResourceExtensions.cs
index 27b79fabf6e..022dc6e97f7 100644
--- a/src/Aspire.Hosting/ApplicationModel/ProjectResourceExtensions.cs
+++ b/src/Aspire.Hosting/ApplicationModel/ProjectResourceExtensions.cs
@@ -1,6 +1,8 @@
// 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;
///
@@ -34,4 +36,34 @@ public static IProjectMetadata GetProjectMetadata(this ProjectResource projectRe
return projectResource.Annotations.OfType().Single();
}
+
+ ///
+ /// Attempts to retrieve the last annotation from the specified resource.
+ ///
+ /// The resource to inspect.
+ /// When this method returns, contains the last annotation, if found; otherwise, .
+ /// if a project metadata annotation was found; otherwise, .
+ [AspireExportIgnore(Reason = "Project annotation inspection helper — not part of the ATS surface.")]
+ internal static bool TryGetProjectAnnotation(this IResource resource, [NotNullWhen(true)] out IProjectMetadata? projectMetadata)
+ {
+ return resource.TryGetLastAnnotation(out projectMetadata);
+ }
+
+ ///
+ /// Returns resources that are project-like via .
+ ///
+ /// The distributed application model.
+ /// The project-like resources discovered in the model.
+ internal static IEnumerable GetProjectAnnotatedResources(this DistributedApplicationModel model)
+ {
+ ArgumentNullException.ThrowIfNull(model);
+
+ foreach (var resource in model.Resources)
+ {
+ if (resource.TryGetProjectAnnotation(out _))
+ {
+ yield return resource;
+ }
+ }
+ }
}
diff --git a/src/Aspire.Hosting/ApplicationModel/ResourceExtensions.cs b/src/Aspire.Hosting/ApplicationModel/ResourceExtensions.cs
index 8533253d008..96ad7f99009 100644
--- a/src/Aspire.Hosting/ApplicationModel/ResourceExtensions.cs
+++ b/src/Aspire.Hosting/ApplicationModel/ResourceExtensions.cs
@@ -820,7 +820,7 @@ public static IReadOnlyList ResolveEndpoints(this IResource re
};
// Track HTTP schemes encountered for ProjectResources
- if (resource is ProjectResource && IsHttpScheme(endpoint.UriScheme))
+ if (resource.TryGetProjectAnnotation(out _) && IsHttpScheme(endpoint.UriScheme))
{
httpSchemesEncountered.Add(endpoint.UriScheme);
}
@@ -957,7 +957,7 @@ public static bool RequiresImageBuild(this IResource resource)
return false;
}
- return resource is ProjectResource || resource.TryGetLastAnnotation(out _);
+ return resource.TryGetProjectAnnotation(out _) || resource.TryGetLastAnnotation(out _);
}
///
@@ -1755,17 +1755,37 @@ private static void CollectHostUrlDependencies(
///
/// Gets the resource type string for the specified resource.
///
- internal static string GetResourceType(this IResource resource) => resource switch
- {
- ProjectResource => KnownResourceTypes.Project,
- ContainerResource => KnownResourceTypes.Container,
- ContainerExecutableResource => KnownResourceTypes.ContainerExec,
- DotnetToolResource => KnownResourceTypes.Tool,
- ExecutableResource => KnownResourceTypes.Executable,
- ParameterResource => KnownResourceTypes.Parameter,
- ConnectionStringResource => KnownResourceTypes.ConnectionString,
- ExternalServiceResource => KnownResourceTypes.ExternalService,
- _ => resource.GetType().Name
- };
+ internal static string GetResourceType(this IResource resource)
+ {
+ if (resource.TryGetProjectAnnotation(out _))
+ {
+ return KnownResourceTypes.Project;
+ }
+
+ if (resource.TryGetLastAnnotation(out _))
+ {
+ return KnownResourceTypes.Container;
+ }
+
+ if (resource.TryGetAnnotationsOfType(out _))
+ {
+ return KnownResourceTypes.Tool;
+ }
+
+ if (resource.TryGetExecutableAnnotation(out _))
+ {
+ return resource is ContainerExecutableResource
+ ? KnownResourceTypes.ContainerExec
+ : KnownResourceTypes.Executable;
+ }
+
+ return resource switch
+ {
+ ParameterResource => KnownResourceTypes.Parameter,
+ ConnectionStringResource => KnownResourceTypes.ConnectionString,
+ ExternalServiceResource => KnownResourceTypes.ExternalService,
+ _ => resource.GetType().Name
+ };
+ }
#pragma warning restore ASPIREDOTNETTOOL
}
diff --git a/src/Aspire.Hosting/BuiltInDistributedApplicationEventSubscriptionHandlers.cs b/src/Aspire.Hosting/BuiltInDistributedApplicationEventSubscriptionHandlers.cs
index 7a2ece308aa..2147c4346f0 100644
--- a/src/Aspire.Hosting/BuiltInDistributedApplicationEventSubscriptionHandlers.cs
+++ b/src/Aspire.Hosting/BuiltInDistributedApplicationEventSubscriptionHandlers.cs
@@ -24,7 +24,7 @@ public static Task InitializeDcpAnnotations(BeforeStartEvent beforeStartEvent, C
nameGenerator.EnsureDcpInstancesPopulated(container);
}
- foreach (var executable in beforeStartEvent.Model.GetExecutableResources())
+ foreach (var executable in beforeStartEvent.Model.GetExecutableAnnotatedResources())
{
nameGenerator.EnsureDcpInstancesPopulated(executable);
}
@@ -34,7 +34,7 @@ public static Task InitializeDcpAnnotations(BeforeStartEvent beforeStartEvent, C
nameGenerator.EnsureDcpInstancesPopulated(containerExec);
}
- foreach (var project in beforeStartEvent.Model.GetProjectResources())
+ foreach (var project in beforeStartEvent.Model.GetProjectAnnotatedResources())
{
nameGenerator.EnsureDcpInstancesPopulated(project);
}
diff --git a/src/Aspire.Hosting/Dcp/DcpExecutor.cs b/src/Aspire.Hosting/Dcp/DcpExecutor.cs
index 58a6a649ebe..5c7dd3f6bd4 100644
--- a/src/Aspire.Hosting/Dcp/DcpExecutor.cs
+++ b/src/Aspire.Hosting/Dcp/DcpExecutor.cs
@@ -377,7 +377,7 @@ internal static string GetResourceType(T resource, IResource appModelResource
return resource switch
{
Container => KnownResourceTypes.Container,
- Executable => appModelResource is ProjectResource ? KnownResourceTypes.Project : KnownResourceTypes.Executable,
+ Executable => appModelResource.TryGetProjectAnnotation(out _) ? KnownResourceTypes.Project : KnownResourceTypes.Executable,
ContainerExec => KnownResourceTypes.ContainerExec,
_ => throw new InvalidOperationException($"Unknown resource type {resource.GetType().Name}")
};
diff --git a/src/Aspire.Hosting/Dcp/DcpNameGenerator.cs b/src/Aspire.Hosting/Dcp/DcpNameGenerator.cs
index 4ef706d4a62..c4de06f47e6 100644
--- a/src/Aspire.Hosting/Dcp/DcpNameGenerator.cs
+++ b/src/Aspire.Hosting/Dcp/DcpNameGenerator.cs
@@ -46,12 +46,7 @@ public void EnsureDcpInstancesPopulated(IResource resource)
var (name, suffix) = GetContainerName(resource);
AddInstancesAnnotation(resource, [new DcpInstance(name, suffix, 0)]);
}
- else if (resource is ExecutableResource or ContainerExecutableResource)
- {
- var (name, suffix) = GetExecutableName(resource);
- AddInstancesAnnotation(resource, [new DcpInstance(name, suffix, 0)]);
- }
- else if (resource is ProjectResource)
+ else if (resource.TryGetProjectAnnotation(out _))
{
var replicas = resource.GetReplicaCount();
var builder = ImmutableArray.CreateBuilder(replicas);
@@ -62,6 +57,11 @@ public void EnsureDcpInstancesPopulated(IResource resource)
}
AddInstancesAnnotation(resource, builder.ToImmutable());
}
+ else if (resource is ContainerExecutableResource || resource.TryGetExecutableAnnotation(out _))
+ {
+ var (name, suffix) = GetExecutableName(resource);
+ AddInstancesAnnotation(resource, [new DcpInstance(name, suffix, 0)]);
+ }
}
private static void AddInstancesAnnotation(IResource resource, ImmutableArray instances)
@@ -71,7 +71,7 @@ private static void AddInstancesAnnotation(IResource resource, ImmutableArray er, EmptyC
private void PrepareProjectExecutables()
{
- var modelProjectResources = _model.GetProjectResources();
-
- foreach (var project in modelProjectResources)
+ foreach (var project in _model.GetProjectAnnotatedResources())
{
- if (!project.TryGetLastAnnotation(out var projectMetadata))
+ if (!project.TryGetProjectAnnotation(out var projectMetadata))
{
- throw new InvalidOperationException($"Project resource '{project.Name}' is missing required metadata."); // Should never happen.
+ throw new InvalidOperationException($"Project resource '{project.Name}' is missing required project metadata annotation, despite being returned from GetProjectAnnotatedResources().");
}
EnsureRequiredAnnotations(project);
@@ -292,18 +290,21 @@ private void PrepareProjectExecutables()
private void PreparePlainExecutables()
{
- var modelExecutableResources = _model.GetExecutableResources();
-
- foreach (var executable in modelExecutableResources)
+ foreach (var executable in _model.GetExecutableAnnotatedResources())
{
+ if (!executable.TryGetExecutableAnnotation(out var executableAnnotation))
+ {
+ throw new InvalidOperationException($"Executable resource '{executable.Name}' is missing required executable annotation, despite being returned from GetExecutableAnnotatedResources().");
+ }
+
EnsureRequiredAnnotations(executable);
var exeInstance = DcpExecutor.GetDcpInstance(executable, instanceIndex: 0);
- var exePath = executable.Command;
+ var exePath = executableAnnotation.Command;
var exe = Executable.Create(exeInstance.Name, exePath);
// The working directory is always relative to the app host project directory (if it exists).
- exe.Spec.WorkingDirectory = executable.WorkingDirectory;
+ exe.Spec.WorkingDirectory = executableAnnotation.WorkingDirectory;
exe.Annotate(CustomResource.OtelServiceNameAnnotation, executable.Name);
exe.Annotate(CustomResource.OtelServiceInstanceIdAnnotation, executable.GetOtelServiceInstanceId(exeInstance));
exe.Annotate(CustomResource.ResourceNameAnnotation, executable.Name);
@@ -488,7 +489,7 @@ LaunchArgument CreateLaunchArgument(string value, bool isSensitive, bool executa
}
// If the executable is a project then include any command line args from the launch profile.
- if (er.ModelResource is ProjectResource project)
+ if (er.ModelResource.TryGetProjectAnnotation(out _))
{
// Args in the launch profile is used when:
// 1. The project is run as an executable. Launch profile args are combined with app host supplied args.
@@ -499,7 +500,8 @@ LaunchArgument CreateLaunchArgument(string value, bool isSensitive, bool executa
// We still want to display the args in the dashboard so only add them to the custom arg annotations.
var executableArg = spec.ExecutionType != ExecutionType.IDE;
- var launchProfileArgs = GetLaunchProfileArgs(project.GetEffectiveLaunchProfile()?.LaunchProfile);
+ LaunchProfile? launchProfile = er.ModelResource.GetEffectiveLaunchProfile()?.LaunchProfile;
+ var launchProfileArgs = GetLaunchProfileArgs(launchProfile);
if (launchProfileArgs.Count > 0 && appHostArgList.Count > 0)
{
// If there are app host args, add a double-dash to separate them from the launch args.
@@ -509,7 +511,7 @@ LaunchArgument CreateLaunchArgument(string value, bool isSensitive, bool executa
launchArgs.AddRange(launchProfileArgs.Select(a => CreateLaunchArgument(a, isSensitive: false, executableArg, display: true)));
}
}
- else if (er.ModelResource is DotnetToolResource)
+ else if (er.ModelResource.TryGetAnnotationsOfType(out _))
{
var argSeparator = appHostArgList.Select((a, i) => (index: i, value: a.Value))
.FirstOrDefault(x => x.value == DotnetToolResourceExtensions.ArgumentSeparator);
@@ -547,7 +549,7 @@ private bool ShouldFallBackToIdeExecution(bool isInDebugSession, SupportsDebuggi
return true;
}
- private ProjectLaunchConfiguration CreateProjectLaunchConfiguration(ProjectResource project, IProjectMetadata projectMetadata)
+ private ProjectLaunchConfiguration CreateProjectLaunchConfiguration(IResource project, IProjectMetadata projectMetadata)
{
var projectLaunchConfiguration = new ProjectLaunchConfiguration();
projectLaunchConfiguration.ProjectPath = projectMetadata.ProjectPath;
@@ -555,7 +557,7 @@ private ProjectLaunchConfiguration CreateProjectLaunchConfiguration(ProjectResou
?? (Debugger.IsAttached ? ExecutableLaunchMode.Debug : ExecutableLaunchMode.NoDebug);
projectLaunchConfiguration.DisableLaunchProfile = project.TryGetLastAnnotation(out _);
- // Use the effective launch profile which has fallback logic
+
if (!projectLaunchConfiguration.DisableLaunchProfile && project.GetEffectiveLaunchProfile() is NamedLaunchProfile namedLaunchProfile)
{
projectLaunchConfiguration.LaunchProfile = namedLaunchProfile.Name;
diff --git a/src/Aspire.Hosting/Dcp/ResourceSnapshotBuilder.cs b/src/Aspire.Hosting/Dcp/ResourceSnapshotBuilder.cs
index 39856c8d2ed..f6b67bf1b81 100644
--- a/src/Aspire.Hosting/Dcp/ResourceSnapshotBuilder.cs
+++ b/src/Aspire.Hosting/Dcp/ResourceSnapshotBuilder.cs
@@ -136,10 +136,10 @@ public CustomResourceSnapshot ToSnapshot(Executable executable, CustomResourceSn
if (executable.AppModelResourceName is not null &&
_resourceState.ApplicationModel.TryGetValue(executable.AppModelResourceName, out appModelResource))
{
- if (appModelResource is ProjectResource projectResource)
+ if (appModelResource.TryGetProjectAnnotation(out var projectMetadata))
{
- projectPath = projectResource.GetProjectMetadata().ProjectPath;
- launchProfileName = projectResource.GetEffectiveLaunchProfile()?.Name;
+ projectPath = projectMetadata.ProjectPath;
+ launchProfileName = appModelResource.GetEffectiveLaunchProfile()?.Name;
}
}
diff --git a/src/Aspire.Hosting/ExecutableResourceExtensions.cs b/src/Aspire.Hosting/ExecutableResourceExtensions.cs
index 3f9766081a8..3abbe8d6483 100644
--- a/src/Aspire.Hosting/ExecutableResourceExtensions.cs
+++ b/src/Aspire.Hosting/ExecutableResourceExtensions.cs
@@ -1,6 +1,7 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
+using System.Diagnostics.CodeAnalysis;
using Aspire.Hosting.ApplicationModel;
namespace Aspire.Hosting;
@@ -22,4 +23,34 @@ public static IEnumerable GetExecutableResources(this Distri
return model.Resources.OfType();
}
+
+ ///
+ /// Returns resources that are executable via .
+ ///
+ /// The distributed application model to inspect.
+ /// The executable resources discovered in the model.
+ internal static IEnumerable GetExecutableAnnotatedResources(this DistributedApplicationModel model)
+ {
+ ArgumentNullException.ThrowIfNull(model);
+
+ foreach (var resource in model.Resources)
+ {
+ if (!resource.TryGetProjectAnnotation(out _) && resource.TryGetExecutableAnnotation(out _))
+ {
+ yield return resource;
+ }
+ }
+ }
+
+ ///
+ /// Attempts to retrieve the last from the specified resource.
+ ///
+ /// The resource to inspect.
+ /// When this method returns, contains the last , if found; otherwise, .
+ /// if an executable annotation was found; otherwise, .
+ [AspireExportIgnore(Reason = "Executable annotation inspection helper — not part of the ATS surface.")]
+ internal static bool TryGetExecutableAnnotation(this IResource resource, [NotNullWhen(true)] out ExecutableAnnotation? executableAnnotation)
+ {
+ return resource.TryGetLastAnnotation(out executableAnnotation);
+ }
}
diff --git a/src/Aspire.Hosting/Orchestrator/ApplicationOrchestrator.cs b/src/Aspire.Hosting/Orchestrator/ApplicationOrchestrator.cs
index 67458641902..dafd16b5549 100644
--- a/src/Aspire.Hosting/Orchestrator/ApplicationOrchestrator.cs
+++ b/src/Aspire.Hosting/Orchestrator/ApplicationOrchestrator.cs
@@ -723,8 +723,8 @@ private async Task PublishEventToHierarchy(Func creat
// resources that may want to have their own lifetime, that this code will be unaware of.
private static bool ResourceHasOwnLifetime(IResource resource) =>
resource.IsContainer() ||
- resource is ProjectResource ||
- resource is ExecutableResource ||
+ resource.TryGetProjectAnnotation(out _) ||
+ resource.TryGetExecutableAnnotation(out _) ||
resource is ParameterResource ||
resource is ConnectionStringResource ||
resource is ExternalServiceResource;
diff --git a/src/Aspire.Hosting/Publishing/ManifestPublishingContext.cs b/src/Aspire.Hosting/Publishing/ManifestPublishingContext.cs
index d012fc0b7a4..f2c9d0ccb97 100644
--- a/src/Aspire.Hosting/Publishing/ManifestPublishingContext.cs
+++ b/src/Aspire.Hosting/Publishing/ManifestPublishingContext.cs
@@ -174,7 +174,7 @@ private Task WriteConnectionStringAsync(IResourceWithConnectionString resource)
private async Task WriteProjectAsync(ProjectResource project)
{
- if (!project.TryGetLastAnnotation(out var metadata))
+ if (!project.TryGetProjectAnnotation(out var metadata))
{
throw new DistributedApplicationException($"Project metadata was not found for resource '{project.Name}'.");
}
diff --git a/src/Aspire.Hosting/Publishing/ResourceContainerImageManager.cs b/src/Aspire.Hosting/Publishing/ResourceContainerImageManager.cs
index f4782b00e96..8b3d0b52a94 100644
--- a/src/Aspire.Hosting/Publishing/ResourceContainerImageManager.cs
+++ b/src/Aspire.Hosting/Publishing/ResourceContainerImageManager.cs
@@ -259,7 +259,7 @@ public async Task BuildImageAsync(IResource resource, CancellationToken cancella
logger.LogDebug("{ContainerRuntimeName} is healthy", containerRuntime.Name);
}
- if (resource is ProjectResource)
+ if (resource.TryGetProjectAnnotation(out _))
{
// If it is a project resource we need to build the container image
// using the .NET SDK.
@@ -327,7 +327,7 @@ private async Task BuildProjectContainerImageAsync(IResource resource, ResolvedC
private async Task ExecuteDotnetPublishAsync(IResource resource, ResolvedContainerBuildOptions options, CancellationToken cancellationToken)
{
// This is a resource project so we'll use the .NET SDK to build the container image.
- if (!resource.TryGetLastAnnotation(out var projectMetadata))
+ if (!resource.TryGetProjectAnnotation(out var projectMetadata))
{
throw new DistributedApplicationException($"The resource '{resource.Name}' does not have a project metadata annotation.");
}
diff --git a/src/Shared/LaunchProfiles/LaunchProfileExtensions.cs b/src/Shared/LaunchProfiles/LaunchProfileExtensions.cs
index c42d914c5ba..5d208f9ca8b 100644
--- a/src/Shared/LaunchProfiles/LaunchProfileExtensions.cs
+++ b/src/Shared/LaunchProfiles/LaunchProfileExtensions.cs
@@ -30,16 +30,16 @@ internal static class LaunchProfileExtensions
return projectMetadata.GetLaunchSettings(projectResource.Name);
}
- internal static NamedLaunchProfile? GetEffectiveLaunchProfile(this ProjectResource projectResource, bool throwIfNotFound = false)
+ internal static NamedLaunchProfile? GetEffectiveLaunchProfile(this IResource resource, bool throwIfNotFound = false)
{
- string? launchProfileName = projectResource.SelectLaunchProfileName();
+ string? launchProfileName = resource.SelectLaunchProfileName();
if (string.IsNullOrEmpty(launchProfileName))
{
return null;
}
- var launchProfile = projectResource.GetLaunchProfile(launchProfileName, throwIfNotFound);
- if (launchProfile == null)
+ var launchProfile = resource.GetLaunchProfile(launchProfileName, throwIfNotFound);
+ if (launchProfile is null)
{
return null;
}
@@ -47,9 +47,14 @@ internal static class LaunchProfileExtensions
return new NamedLaunchProfile(launchProfileName, launchProfile);
}
- internal static LaunchProfile? GetLaunchProfile(this ProjectResource projectResource, string launchProfileName, bool throwIfNotFound = false)
+ internal static LaunchProfile? GetLaunchProfile(this IResource resource, string launchProfileName, bool throwIfNotFound = false)
{
- var profiles = projectResource.GetLaunchSettings()?.Profiles;
+ if (!resource.TryGetLastAnnotation(out var projectMetadata))
+ {
+ return null;
+ }
+
+ var profiles = projectMetadata.GetLaunchSettings(resource.Name)?.Profiles;
if (profiles is null)
{
return null;
@@ -118,9 +123,9 @@ internal static class LaunchProfileExtensions
TrySelectLaunchProfileByOrder
];
- private static bool TrySelectLaunchProfileByOrder(ProjectResource projectResource, [NotNullWhen(true)] out string? launchProfileName)
+ private static bool TrySelectLaunchProfileByOrder(IResource resource, IProjectMetadata projectMetadata, [NotNullWhen(true)] out string? launchProfileName)
{
- var launchSettings = GetLaunchSettings(projectResource);
+ var launchSettings = projectMetadata.GetLaunchSettings(resource.Name);
if (launchSettings == null || launchSettings.Profiles.Count == 0)
{
@@ -142,16 +147,16 @@ private static bool TrySelectLaunchProfileByOrder(ProjectResource projectResourc
return false;
}
- private static bool TrySelectLaunchProfileFromDefaultAnnotation(ProjectResource projectResource, [NotNullWhen(true)] out string? launchProfileName)
+ private static bool TrySelectLaunchProfileFromDefaultAnnotation(IResource resource, IProjectMetadata projectMetadata, [NotNullWhen(true)] out string? launchProfileName)
{
- if (!projectResource.TryGetLastAnnotation(out var launchProfileAnnotation))
+ if (!resource.TryGetLastAnnotation(out var launchProfileAnnotation))
{
launchProfileName = null;
return false;
}
var appHostDefaultLaunchProfileName = launchProfileAnnotation.LaunchProfileName;
- var launchSettings = GetLaunchSettings(projectResource);
+ var launchSettings = projectMetadata.GetLaunchSettings(resource.Name);
if (launchSettings == null)
{
launchProfileName = null;
@@ -168,9 +173,9 @@ private static bool TrySelectLaunchProfileFromDefaultAnnotation(ProjectResource
return true;
}
- private static bool TrySelectLaunchProfileFromAnnotation(ProjectResource projectResource, [NotNullWhen(true)] out string? launchProfileName)
+ private static bool TrySelectLaunchProfileFromAnnotation(IResource resource, IProjectMetadata projectMetadata, [NotNullWhen(true)] out string? launchProfileName)
{
- if (projectResource.TryGetLastAnnotation(out var launchProfileAnnotation))
+ if (resource.TryGetLastAnnotation(out var launchProfileAnnotation))
{
launchProfileName = launchProfileAnnotation.LaunchProfileName;
return true;
@@ -182,17 +187,26 @@ private static bool TrySelectLaunchProfileFromAnnotation(ProjectResource project
}
}
- internal static string? SelectLaunchProfileName(this ProjectResource projectResource)
+ internal static string? SelectLaunchProfileName(this IResource resource)
+ {
+ if (!resource.TryGetLastAnnotation(out var projectMetadata))
+ {
+ return null;
+ }
+ return SelectLaunchProfileName(resource, projectMetadata);
+ }
+
+ private static string? SelectLaunchProfileName(IResource resource, IProjectMetadata projectMetadata)
{
// ExcludeLaunchProfileAnnotation takes precedence over all other launch profile selectors.
- if (projectResource.TryGetLastAnnotation(out _))
+ if (resource.TryGetLastAnnotation(out _))
{
return null;
}
foreach (var launchProfileSelector in s_launchProfileSelectors)
{
- if (launchProfileSelector(projectResource, out var launchProfile))
+ if (launchProfileSelector(resource, projectMetadata, out var launchProfile))
{
return launchProfile;
}
@@ -204,4 +218,4 @@ private static bool TrySelectLaunchProfileFromAnnotation(ProjectResource project
internal sealed record class NamedLaunchProfile(string Name, LaunchProfile LaunchProfile);
-internal delegate bool LaunchProfileSelector(ProjectResource project, out string? launchProfile);
+internal delegate bool LaunchProfileSelector(IResource resource, IProjectMetadata projectMetadata, out string? launchProfile);
diff --git a/tests/Aspire.Hosting.Python.Tests/AddPythonAppTests.cs b/tests/Aspire.Hosting.Python.Tests/AddPythonAppTests.cs
index cfb5625ded8..ee22230f751 100644
--- a/tests/Aspire.Hosting.Python.Tests/AddPythonAppTests.cs
+++ b/tests/Aspire.Hosting.Python.Tests/AddPythonAppTests.cs
@@ -154,10 +154,7 @@ public async Task AddPythonApp_SetsResourcePropertiesCorrectly()
var app = builder.Build();
var appModel = app.Services.GetRequiredService();
- var executableResources = appModel.GetExecutableResources();
-
- // Filter to get only the PythonAppResource (pip installer may also be present if requirements.txt exists)
- var pythonProjectResource = executableResources.OfType().Single();
+ var pythonProjectResource = Assert.Single(appModel.Resources.OfType());
Assert.Equal("pythonProject", pythonProjectResource.Name);
Assert.Equal(projectDirectory, pythonProjectResource.WorkingDirectory);
@@ -193,10 +190,7 @@ public async Task AddPythonApp_ObsoleteMethod_StillWorks()
var app = builder.Build();
var appModel = app.Services.GetRequiredService();
- var executableResources = appModel.GetExecutableResources();
-
- // Filter to get only the PythonAppResource (pip installer may also be present if requirements.txt exists)
- var pythonProjectResource = executableResources.OfType().Single();
+ var pythonProjectResource = Assert.Single(appModel.Resources.OfType());
Assert.Equal("pythonProject", pythonProjectResource.Name);
Assert.Equal(projectDirectory, pythonProjectResource.WorkingDirectory);
@@ -236,10 +230,7 @@ public async Task AddPythonAppWithScriptArgs_IncludesTheArguments()
var app = builder.Build();
var appModel = app.Services.GetRequiredService();
- var executableResources = appModel.GetExecutableResources();
-
- // Filter to get only the PythonAppResource (pip installer may also be present if requirements.txt exists)
- var pythonProjectResource = executableResources.OfType().Single();
+ var pythonProjectResource = Assert.Single(appModel.Resources.OfType());
Assert.Equal("pythonProject", pythonProjectResource.Name);
Assert.Equal(projectDirectory, pythonProjectResource.WorkingDirectory);
@@ -412,9 +403,7 @@ public async Task WithVirtualEnvironment_UpdatesCommandToUseNewVirtualEnvironmen
var app = builder.Build();
var appModel = app.Services.GetRequiredService();
- var executableResources = appModel.GetExecutableResources();
-
- var pythonProjectResource = Assert.Single(executableResources.OfType());
+ var pythonProjectResource = Assert.Single(appModel.Resources.OfType());
var expectedProjectDirectory = Path.GetFullPath(Path.Combine(builder.AppHostDirectory, tempDir.Path));
@@ -445,9 +434,7 @@ public async Task WithVirtualEnvironment_SupportsAbsolutePath()
var app = builder.Build();
var appModel = app.Services.GetRequiredService();
- var executableResources = appModel.GetExecutableResources();
-
- var pythonProjectResource = Assert.Single(executableResources.OfType());
+ var pythonProjectResource = Assert.Single(appModel.Resources.OfType());
if (OperatingSystem.IsWindows())
{
@@ -506,9 +493,7 @@ public async Task WithVirtualEnvironment_CanBeChainedWithOtherExtensions()
var app = builder.Build();
var appModel = app.Services.GetRequiredService();
- var executableResources = appModel.GetExecutableResources();
-
- var pythonProjectResource = Assert.Single(executableResources.OfType());
+ var pythonProjectResource = Assert.Single(appModel.Resources.OfType());
var commandArguments = await ArgumentEvaluator.GetArgumentListAsync(pythonProjectResource, TestServiceProvider.Instance);
Assert.Equal(3, commandArguments.Count);
@@ -536,9 +521,7 @@ public void WithVirtualEnvironment_UsesAppDirectoryWhenVenvExistsThere()
var app = builder.Build();
var appModel = app.Services.GetRequiredService();
- var executableResources = appModel.GetExecutableResources();
-
- var pythonProjectResource = Assert.Single(executableResources.OfType());
+ var pythonProjectResource = Assert.Single(appModel.Resources.OfType());
// Should use the app directory .venv since it exists there
var expectedProjectDirectory = Path.GetFullPath(Path.Combine(builder.AppHostDirectory, tempAppDir.Path));
@@ -572,9 +555,7 @@ public void WithVirtualEnvironment_UsesAppHostDirectoryWhenVenvOnlyExistsThere()
var app = builder.Build();
var appModel = app.Services.GetRequiredService();
- var executableResources = appModel.GetExecutableResources();
-
- var pythonProjectResource = Assert.Single(executableResources.OfType());
+ var pythonProjectResource = Assert.Single(appModel.Resources.OfType());
// Should use the AppHost directory .venv since it only exists there
AssertPythonCommandPath(appHostVenvPath, pythonProjectResource.Command);
@@ -621,9 +602,7 @@ public void WithVirtualEnvironment_PrefersAppDirectoryWhenVenvExistsInBoth()
var app = builder.Build();
var appModel = app.Services.GetRequiredService();
- var executableResources = appModel.GetExecutableResources();
-
- var pythonProjectResource = Assert.Single(executableResources.OfType());
+ var pythonProjectResource = Assert.Single(appModel.Resources.OfType());
// Should prefer the app directory .venv when it exists in both locations
AssertPythonCommandPath(appVenvPath, pythonProjectResource.Command);
@@ -655,9 +634,7 @@ public void WithVirtualEnvironment_DefaultsToAppDirectoryWhenVenvExistsInNeither
var app = builder.Build();
var appModel = app.Services.GetRequiredService();
- var executableResources = appModel.GetExecutableResources();
-
- var pythonProjectResource = Assert.Single(executableResources.OfType());
+ var pythonProjectResource = Assert.Single(appModel.Resources.OfType());
// Should default to app directory when it doesn't exist in either location
var expectedProjectDirectory = Path.GetFullPath(Path.Combine(builder.AppHostDirectory, tempAppDir.Path));
@@ -698,9 +675,7 @@ public void WithVirtualEnvironment_ExplicitPath_UsesVerbatim()
var app = builder.Build();
var appModel = app.Services.GetRequiredService();
- var executableResources = appModel.GetExecutableResources();
-
- var pythonProjectResource = Assert.Single(executableResources.OfType());
+ var pythonProjectResource = Assert.Single(appModel.Resources.OfType());
// Should use the explicitly specified path, NOT the AppHost .venv
AssertPythonCommandPath(customVenvPath, pythonProjectResource.Command);
@@ -981,9 +956,7 @@ public async Task AddPythonApp_SetsCorrectCommandAndArguments()
var app = builder.Build();
var appModel = app.Services.GetRequiredService();
- var executableResources = appModel.GetExecutableResources();
-
- var pythonProjectResource = Assert.Single(executableResources.OfType());
+ var pythonProjectResource = Assert.Single(appModel.Resources.OfType());
Assert.Equal("pythonProject", pythonProjectResource.Name);
@@ -1017,9 +990,7 @@ public async Task AddPythonModule_SetsCorrectCommandAndArguments()
var app = builder.Build();
var appModel = app.Services.GetRequiredService();
- var executableResources = appModel.GetExecutableResources();
-
- var pythonProjectResource = Assert.Single(executableResources.OfType());
+ var pythonProjectResource = Assert.Single(appModel.Resources.OfType());
var expectedProjectDirectory = Path.GetFullPath(Path.Combine(builder.AppHostDirectory, tempDir.Path));
@@ -1051,9 +1022,7 @@ public async Task AddPythonExecutable_SetsCorrectCommandAndArguments()
var app = builder.Build();
var appModel = app.Services.GetRequiredService();
- var executableResources = appModel.GetExecutableResources();
-
- var pythonProjectResource = Assert.Single(executableResources.OfType());
+ var pythonProjectResource = Assert.Single(appModel.Resources.OfType());
var expectedProjectDirectory = Path.GetFullPath(Path.Combine(builder.AppHostDirectory, tempDir.Path));
diff --git a/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs b/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs
index 640055e77c6..bbeee13a588 100644
--- a/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs
+++ b/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs
@@ -3195,6 +3195,111 @@ public async Task CustomExecutable_DebugSessionInfoContainsType_RunInIde()
Assert.Equal(ExecutionType.IDE, exe.Spec.ExecutionType);
}
+ [Fact]
+ public async Task AnnotatedExecutableResource_IsCreatedFromExecutableAnnotation()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+
+ builder.AddResource(new PlainResource("annotatedExecutable"))
+ .WithAnnotation(new ExecutableAnnotation
+ {
+ Command = "annotated-command",
+ WorkingDirectory = "annotated-working-directory"
+ });
+
+ var kubernetesService = new TestKubernetesService();
+ using var app = builder.Build();
+ var distributedAppModel = app.Services.GetRequiredService();
+ var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService);
+
+ await appExecutor.RunApplicationAsync();
+
+ var exe = GetCreatedExecutableForResource(kubernetesService, "annotatedExecutable");
+ Assert.Equal("annotated-command", exe.Spec.ExecutablePath);
+ Assert.Equal("annotated-working-directory", exe.Spec.WorkingDirectory);
+ Assert.Equal(ExecutionType.Process, exe.Spec.ExecutionType);
+ }
+
+ [Fact]
+ public async Task AnnotatedProjectResource_IsCreatedFromProjectMetadataAndNotAsPlainExecutable()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+
+ builder.AddResource(new PlainResource("annotatedProject"))
+ .WithAnnotation(new TestProject())
+ .WithAnnotation(new ExecutableAnnotation
+ {
+ Command = "should-not-be-used",
+ WorkingDirectory = "ignored-working-directory"
+ });
+
+ var kubernetesService = new TestKubernetesService();
+ using var app = builder.Build();
+ var distributedAppModel = app.Services.GetRequiredService();
+ var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService);
+
+ await appExecutor.RunApplicationAsync();
+
+ var exe = Assert.Single(GetCreatedExecutablesForResource(kubernetesService, "annotatedProject"));
+ Assert.Equal("dotnet", exe.Spec.ExecutablePath);
+ Assert.Equal(ExecutionType.Process, exe.Spec.ExecutionType);
+ Assert.NotNull(exe.Spec.Args);
+ Assert.Contains("run", exe.Spec.Args!);
+ Assert.Contains("--project", exe.Spec.Args!);
+ Assert.Contains("TestProject", exe.Spec.Args!);
+ Assert.Contains("--no-launch-profile", exe.Spec.Args!);
+ }
+
+ [Fact]
+ public async Task ContainerResource_WithContainerAnnotationRemovedAndExecutableAnnotationAdded_CreatesExecutableNotContainer()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+
+ var resource = builder.AddContainer("mutated-container", "image");
+ resource.Resource.Annotations.Remove(resource.Resource.Annotations.OfType().Single());
+ resource.WithAnnotation(new ExecutableAnnotation
+ {
+ Command = "mutated-command",
+ WorkingDirectory = "mutated-working-directory"
+ });
+
+ var kubernetesService = new TestKubernetesService();
+ using var app = builder.Build();
+ var distributedAppModel = app.Services.GetRequiredService();
+ var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService);
+
+ await appExecutor.RunApplicationAsync();
+
+ var executable = Assert.Single(kubernetesService.CreatedResources.OfType(), e => e.AppModelResourceName == "mutated-container");
+ Assert.Equal("mutated-command", executable.Spec.ExecutablePath);
+ Assert.Equal("mutated-working-directory", executable.Spec.WorkingDirectory);
+ Assert.DoesNotContain(kubernetesService.CreatedResources.OfType(), c => c.AppModelResourceName == "mutated-container");
+ }
+
+ [Fact]
+ public async Task ExecutableResource_WithExecutableAnnotationRemovedAndContainerAnnotationAdded_CreatesContainerNotExecutable()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+
+ var resource = builder.AddExecutable("mutated-executable", "old-command", "old-working-directory");
+ resource.Resource.Annotations.Remove(resource.Resource.Annotations.OfType().Single());
+ resource.WithAnnotation(new ContainerImageAnnotation
+ {
+ Image = "mutated-image",
+ Tag = "latest"
+ });
+
+ var kubernetesService = new TestKubernetesService();
+ using var app = builder.Build();
+ var distributedAppModel = app.Services.GetRequiredService();
+ var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService);
+
+ await appExecutor.RunApplicationAsync();
+
+ Assert.Single(kubernetesService.CreatedResources.OfType(), c => c.AppModelResourceName == "mutated-executable");
+ Assert.DoesNotContain(kubernetesService.CreatedResources.OfType(), e => e.AppModelResourceName == "mutated-executable");
+ }
+
[Fact]
public async Task ProjectExecutable_NoDebugSessionInfo_DefaultsToProjectSupport()
{
@@ -4983,6 +5088,8 @@ private static X509Certificate2 CreateTestCertificate()
serialNumber);
}
+ private sealed class PlainResource(string name) : Resource(name), IComputeResource;
+
private sealed class TestExecutableResource(string directory) : ExecutableResource("TestExecutable", "test", directory);
private sealed class TestOtherExecutableResource(string directory) : ExecutableResource("TestOtherExecutable", "test-other", directory);
diff --git a/tests/Aspire.Hosting.Tests/ResourceCommandAnnotationTests.cs b/tests/Aspire.Hosting.Tests/ResourceCommandAnnotationTests.cs
index f84cd892373..f2eb59b2bbd 100644
--- a/tests/Aspire.Hosting.Tests/ResourceCommandAnnotationTests.cs
+++ b/tests/Aspire.Hosting.Tests/ResourceCommandAnnotationTests.cs
@@ -92,6 +92,7 @@ public void RestartCommand_ProjectResource_HasDetailedDescription()
// Arrange
var builder = DistributedApplication.CreateBuilder();
var projectResource = new ProjectResource("testproject");
+ projectResource.Annotations.Add(new ProjectMetadata("test.csproj"));
projectResource.AddLifeCycleCommands();
// Act
@@ -107,6 +108,7 @@ public void RestartCommand_CSharpAppResource_HasDetailedDescription()
// Arrange
var builder = DistributedApplication.CreateBuilder();
var csharpAppResource = new CSharpAppResource("testapp");
+ csharpAppResource.Annotations.Add(new ProjectMetadata("testapp.cs"));
csharpAppResource.AddLifeCycleCommands();
// Act
@@ -132,6 +134,7 @@ public void RestartCommand_CSharpAppResource_HasDetailedDescription()
public void RebuildCommand_CommandState(string commandName, string? resourceState, ResourceCommandState commandState)
{
var projectResource = new ProjectResource("testproject");
+ projectResource.Annotations.Add(new ProjectMetadata("test.csproj"));
projectResource.AddLifeCycleCommands();
var rebuildCommand = projectResource.Annotations.OfType().Single(a => a.Name == commandName);
@@ -159,6 +162,7 @@ public void RebuildCommand_OnlyAddedToProjectResources()
containerResource.Resource.AddLifeCycleCommands();
var projectResource = new ProjectResource("testproject");
+ projectResource.Annotations.Add(new ProjectMetadata("test.csproj"));
projectResource.AddLifeCycleCommands();
Assert.DoesNotContain(containerResource.Resource.Annotations.OfType(), a => a.Name == KnownResourceCommands.RebuildCommand);
@@ -169,6 +173,7 @@ public void RebuildCommand_OnlyAddedToProjectResources()
public void RebuildCommand_ProjectResource_HasDescription()
{
var projectResource = new ProjectResource("testproject");
+ projectResource.Annotations.Add(new ProjectMetadata("test.csproj"));
projectResource.AddLifeCycleCommands();
var rebuildCommand = projectResource.Annotations.OfType().Single(a => a.Name == KnownResourceCommands.RebuildCommand);
diff --git a/tests/Aspire.Hosting.Tests/ResourceNotificationTests.cs b/tests/Aspire.Hosting.Tests/ResourceNotificationTests.cs
index 8f4693e05ea..6fbf0189bdc 100644
--- a/tests/Aspire.Hosting.Tests/ResourceNotificationTests.cs
+++ b/tests/Aspire.Hosting.Tests/ResourceNotificationTests.cs
@@ -56,8 +56,8 @@ public async Task InitialSnapshotResourceTypeMatchesKnownResourceTypes(Type reso
{
IResource resource = resourceType.Name switch
{
- nameof(ProjectResource) => new ProjectResource("test"),
- nameof(ContainerResource) => new ContainerResource("test"),
+ nameof(ProjectResource) => CreateProjectResource(),
+ nameof(ContainerResource) => CreateContainerResource(),
nameof(ExecutableResource) => new ExecutableResource("test", "cmd", "."),
nameof(ParameterResource) => new ParameterResource("test", _ => "value", secret: false),
nameof(ConnectionStringResource) => new ConnectionStringResource("test", ReferenceExpression.Create($"connectionString")),
@@ -85,6 +85,20 @@ public async Task InitialSnapshotResourceTypeMatchesKnownResourceTypes(Type reso
Assert.NotNull(resourceEvent);
Assert.Equal(expectedResourceType, resourceEvent.Snapshot.ResourceType);
+
+ static ProjectResource CreateProjectResource()
+ {
+ var project = new ProjectResource("test");
+ project.Annotations.Add(new ProjectMetadata("test.csproj"));
+ return project;
+ }
+
+ static ContainerResource CreateContainerResource()
+ {
+ var container = new ContainerResource("test");
+ container.Annotations.Add(new ContainerImageAnnotation { Image = "test" });
+ return container;
+ }
}
[Fact]