Skip to content

WIP: Use annotations to determine project & executable resources#18052

Draft
afscrome wants to merge 4 commits into
microsoft:mainfrom
afscrome:resource-substitution
Draft

WIP: Use annotations to determine project & executable resources#18052
afscrome wants to merge 4 commits into
microsoft:mainfrom
afscrome:resource-substitution

Conversation

@afscrome

@afscrome afscrome commented Jun 9, 2026

Copy link
Copy Markdown
Collaborator

Description

This brings the executable and project behaviour in line with containers which are already annotation based.

  • Updated ResourceSnapshotBuilder to utilize project metadata directly from app model resources.
  • Introduced new methods in ExecutableResourceExtensions for retrieving executable annotations and filtering executable resources.
  • Modified ApplicationOrchestrator to check for project and executable annotations when determining resource lifetimes.
  • Adjusted ManifestPublishingContext and ResourceContainerImageManager to use project annotations for metadata retrieval.
  • Enhanced LaunchProfileExtensions to support generic resource handling and improved launch profile selection logic.
  • Added unit tests for annotated executable and project resources to ensure correct behaviour when annotations are modified.
  • Created a new playground project for resource substitution to demonstrate how this annotation based approach can be used to do an improved

This lays some groundwork for #8984, but is far from a complete solution - it simply adds internal support, there are no ergonomic helpers to make it work - it is your responsibility to manipulate the resource annotations as needed.

Whilst Aspire now supports, it does mean y. Many will work (e.g. if they're extension methods on IResourceWithEnvironment, then it will probably work for your needs), but if they're tightly coupled to a specific resource type, you'll

##API Awkwardness

This does now introduce some awkwardness with the DistributedApplicationModel.GetProjectResources and GetExecutableResources apis. They continue to return "All resources of type ExecutableResource and ProjectResource", but that may not quite match up which resources DCP creates as executables / containers.

The confusion with project resources is less worrying than executables

  • Because projects are tied to local resources, it's far less likely projects are used in Libraries, so breaking changes are easier to deal with (less likely hood of needing to wait for a library you consume to respond to the API change)
  • ProjectV2 (Project Resource v2 #16386) may well introduce similar types changes anyway.

The other awkwardness is that if you do try and switch one resource to another type, you may not have the right extension methods on your mutated resource.

Again, this isn't a completely new issue if working with custom resources or eventing, as those scenarios don't always have access to the IResourceBuilder<T> extensions, so you have to directly manipulate annotations in those cases anyway.

That said, these problems only start to happen once you start messing with annotations like this, in which case you can probably deal with the extra complexity it brings.

Test Breaks

These changes may cause some tests to break - specifically tests which manually construct new ContainerResource(), new ProjectResource() or new ExecutableResource() without the annotations, and then try to make an assertion on some behaviour.

This won't affect real app hosts, as if you don't have the annotations, DCP would below up anyway. But it may cause some consumer tests to break. In the aspire repo, there were only two classes of tests affected by this, which I resolved by changing the tests. I considered putting backwards compatibility paths to try and maintain the existing behaviour, but it would add cruft to DCP, for resources that ultimately wouldn't execute anyway, so felt like it wasn't worth it.

  • ResourceNotificationTests
  • ResourceCommandAnnotationTests

Substitution Project

The substitution project shows how with the small change in DCP to make it annotation based, it vastly simplifies the effort to bait and switch a resource. It includes some example helper methods showing how you can swap a project resource for a container and vice versa. It also shows an example for contains.

  • Eventing flows properly from old to new resource because it is the same resource object
  • Bait and switched resources that try to publish updates through the old resource no longer blow up -
    throw new InvalidOperationException($"Resource instance doesn't match resource previously registered with specified resource id '{resourceId}'.");
    .

The bait and switch once Aspire relies on Annotations and works pretty well. The main gotcha is if baiting and switching between a container and a host process, endpoints need a few tweaks

  • host processes often have null target ports to let DCP pick, but containers must have a port specified
  • host processes usually target localhost, but containers may need to listen on all ips

The current implementation in the ResourceSubstitution project also has a few gotchas with EndpointReferences as the AsProject implementation relies on creating a temporary project, stealing it's annotations and then throwing it away. The EndpointReferences in that copy end up pointing to the thrown away resource instance , and then end up being stuck waiting for the discarded resurce to start (which never will), so it needs a further hack to work around that.

That said, the gotchas here pale in comparison to what you have to do right now for a bait and switch. As well as being rather brittle - I think I've had 3 aspire upgrades to this point which have broken the implementation and required work arounds, with 13.4 being the latest. (Although it works again now with the reversions in 13.4.3)

AsProject before this change
   public static IResourceBuilder<ProjectResource<T>> AsProject<T, V>(this IResourceBuilder<T> builder)
       where T : IResourceWithEndpoints, IResourceWithEnvironment
       where V : IProjectMetadata, new()
   {
      var applicationBuilder = builder.ApplicationBuilder;

      // Remove the original resource
      var originalResource = builder.Resource;
      applicationBuilder.Resources.Remove(originalResource);

      // Create the substitute resource, using the original resource's annotations
      var resource = new ProjectResource<T>(originalResource);
      var resourceBuilder = builder.ApplicationBuilder.AddResource(resource);

      // Add extra project annotations
      foreach (var annotation in GetProjectAnnotations())
      {
         resource.Annotations.Add(annotation);
      }

      // Fix ups to join up the new and old resource
      FixAnnotations();
      SetUpEventForwarders();
      SetAspNetCoreUrls();

      return resourceBuilder;

      ResourceAnnotationCollection GetProjectAnnotations()
      {
         // WithProjectDefaults is internal, so create a temporary project resource
         // and steal the resulting annotations.
         var tempProject = builder.ApplicationBuilder.AddProject<V>(
            builder.Resource.Name + Guid.NewGuid().ToString("N")[..8],
            options => options.ExcludeKestrelEndpoints = true);
         applicationBuilder.Resources.Remove(tempProject.Resource);
         applicationBuilder.Resources.Remove(originalResource);

         var projectAnnotations = tempProject.Resource.Annotations;

         var projectEndpoints = projectAnnotations.OfType<EndpointAnnotation>().ToDictionary(x => x.Name);
         var originalEndpoints = originalResource.Annotations.OfType<EndpointAnnotation>().ToDictionary(x => x.Name);

         var sharedEndpoints = projectEndpoints.Keys.Intersect(originalEndpoints.Keys);

         // Merge project endpoints determined from launch settings into the original ones
         foreach (var endpoint in sharedEndpoints)
         {
            var originalEndpoint = originalEndpoints[endpoint];
            var projectEndpoint = projectEndpoints[endpoint];

            // Sync project endpoint configuration into the original endpoint
            // That way if anything has taken a reference to the original annotation, it'll see the changes
            originalEndpoint.IsExternal = projectEndpoint.IsExternal;
            originalEndpoint.Transport = projectEndpoint.Transport;
            originalEndpoint.IsProxied = projectEndpoint.IsProxied;
            originalEndpoint.Port = projectEndpoint.Port;
            originalEndpoint.Protocol = projectEndpoint.Protocol;
            originalEndpoint.TargetHost = projectEndpoint.TargetHost;
            originalEndpoint.TargetPort = projectEndpoint.TargetPort;
            originalEndpoint.Transport = projectEndpoint.Transport;
            originalEndpoint.UriScheme = projectEndpoint.UriScheme;

            projectAnnotations.Remove(projectEndpoint);
         }

         foreach (var endpoint in originalEndpoints.Values)
         {
            if (endpoint.IsProxied && endpoint.Port == endpoint.TargetPort)
            {
               endpoint.TargetPort = null;
            }
         }

         return projectAnnotations;
      }

      void FixAnnotations()
      {
         //Strip out container related annotations
         var containerAnnotations = originalResource.Annotations.OfType<ContainerImageAnnotation>().ToArray();
         foreach (var annotation in containerAnnotations)
         {
            resource.Annotations.Remove(annotation);
         }
      }

      void SetUpEventForwarders()
      {
         // Replay events to inner resources
         applicationBuilder.Eventing.Subscribe<ConnectionStringAvailableEvent>(resource, async (evt, ct) =>
         {
            var innerEvent = new ConnectionStringAvailableEvent(originalResource, evt.Services);
            await applicationBuilder.Eventing.PublishAsync(innerEvent, ct);
         });

         // Can't reply ResourceEndpointsAllocatedEvent because this breaks aspire
         // internally when ResourceNotificationService tires to publish an event
         // for the original resource object, rather than the new one         
/*
         applicationBuilder.Eventing.Subscribe<ResourceEndpointsAllocatedEvent>(resource, async (evt, ct) =>
         {
            var innerEvent = new ResourceEndpointsAllocatedEvent(originalResource, evt.Services);
            await applicationBuilder.Eventing.PublishAsync(innerEvent, ct);
         });
*/
         applicationBuilder.Eventing.Subscribe<ResourceReadyEvent>(resource, async (evt, ct) =>
         {
            var innerEvent = new ResourceReadyEvent(originalResource, evt.Services);
            await applicationBuilder.Eventing.PublishAsync(innerEvent, ct);
         });         

         resourceBuilder.OnBeforeResourceStarted(async (resource, evt, ct) =>
         {
            // Wait annotations cause problems when replaying OnBeforeResourceStarted so remove them for now before re-adding
            var waitAnnotations = originalResource.Annotations.OfType<WaitAnnotation>().ToList();
            foreach (var annotation in waitAnnotations)
            {
               originalResource.Annotations.Remove(annotation);
            }

            var innerEvent = new BeforeResourceStartedEvent(originalResource, evt.Services);
            await applicationBuilder.Eventing.PublishAsync(innerEvent, ct);

            foreach (var annotation in waitAnnotations)
            {
               originalResource.Annotations.Add(annotation);
            }
         });

         resourceBuilder.OnResourceReady(async( resource, evt, ct) =>
         {
            var innerEvent = new ResourceReadyEvent(originalResource, evt.Services);
            await applicationBuilder.Eventing.PublishAsync(innerEvent, ct);
         });

      }

      // Somewhat based on https://github.com/microsoft/aspire/blob/c401f318bc08c57a30b3f91bec437feb93201e02/src/Aspire.Hosting/ProjectResourceBuilderExtensions.cs#L730C1-L775C6
      void SetAspNetCoreUrls()
      {
         builder.WithEnvironment(context =>
         {
            if (context.EnvironmentVariables.ContainsKey("ASPNETCORE_URLS"))
            {
               // If the user has already set ASPNETCORE_URLS, we don't want to override it.
               return;
            }

            var aspnetCoreUrls = new ReferenceExpressionBuilder();

            var processedHttpsPort = false;
            var first = true;

            // Turn http and https endpoints into a single ASPNETCORE_URLS environment variable.
            foreach (var e in builder.Resource.GetEndpoints().Where(x => x.EndpointName is "http" or "https"))
            {
               if (!first)
               {
                  aspnetCoreUrls.AppendLiteral(";");
               }

               if (!processedHttpsPort && e.Scheme == "https")
               {
                  // Add the environment variable for the HTTPS port if we have an HTTPS service. This will make sure the
                  // HTTPS redirection middleware avoids redirecting to the internal port.
                  context.EnvironmentVariables["ASPNETCORE_HTTPS_PORT"] = e.Property(EndpointProperty.Port);
trayp
                  processedHttpsPort = true;
               }

               // Assume target is localhost
               var targetHost = "localhost";

               aspnetCoreUrls.Append($"{e.Property(EndpointProperty.Scheme)}://{targetHost}:{e.Property(EndpointProperty.TargetPort)}");
               first = false;
            }

            if (!aspnetCoreUrls.IsEmpty)
            {
               // Combine into a single expression
               context.EnvironmentVariables["ASPNETCORE_URLS"] = aspnetCoreUrls.Build();
            }
         });
      }
public class ProjectResource<T> : ProjectResource
   where T : IResource
{
   private readonly T _inner;

   internal ProjectResource(T inner) : base(inner.Name)
   {
      _inner = inner;
      // Remove all container annotations to ensure aspire doesn't try to start the resource as both a project and a container
      var containerAnnotations = inner.Annotations.OfType<ContainerImageAnnotation>().ToArray();
      foreach (var annotation in containerAnnotations)
      {
         inner.Annotations.Remove(annotation);
      }
   }

   /// <inheritdoc/>
   public override ResourceAnnotationCollection Annotations => _inner == null ? [] : _inner.Annotations;

   /// <summary>
   /// Converts a <see cref="ProjectResource{T}"/> to the inner resoruce type
   /// </summary>
   /// <param name="resource">The resource to convert</param>
   public static explicit operator T(ProjectResource<T> resource) => resource._inner;
}

Checklist

  • Is this feature complete?
    • Yes. Ready to ship.
    • No. Follow-up changes expected.
  • Are you including unit tests for the changes and scenario tests if relevant?
    • Yes
  • Did you add public API?
    • No - (Although perhaps some of this API should be made public)
  • Does the change make any security assumptions or guarantees?
    • No

…resources, rather than the resource class hierarchy.

This brings the executable and project behaviour in line with containers which are already annotation based.

- Updated ResourceSnapshotBuilder to utilize project metadata directly from app model resources.
- Introduced new methods in ExecutableResourceExtensions for retrieving executable annotations and filtering executable resources.
- Modified ApplicationOrchestrator to check for project and executable annotations when determining resource lifetimes.
- Adjusted ManifestPublishingContext and ResourceContainerImageManager to use project annotations for metadata retrieval.
- Enhanced LaunchProfileExtensions to support generic resource handling and improved launch profile selection logic.
- Added unit tests for annotated executable and project resources to ensure correct behaviour when annotations are modified.
- Created a new playground project for resource substitution with appropriate launch settings and configurations.
Copilot AI review requested due to automatic review settings June 9, 2026 16:49
@github-actions

github-actions Bot commented Jun 9, 2026

Copy link
Copy Markdown
Contributor

🚀 Dogfood this PR with:

⚠️ WARNING: Do not do this without first carefully reviewing the code of this PR to satisfy yourself it is safe.

curl -fsSL https://raw.githubusercontent.com/microsoft/aspire/main/eng/scripts/get-aspire-cli-pr.sh | bash -s -- 18052

Or

  • Run remotely in PowerShell:
iex "& { $(irm https://raw.githubusercontent.com/microsoft/aspire/main/eng/scripts/get-aspire-cli-pr.ps1) } 18052"

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Note

Copilot was unable to run its full agentic suite in this review.

This PR shifts Aspire hosting logic from concrete resource types (e.g., ProjectResource, ExecutableResource) toward annotation-driven detection (IProjectMetadata, ExecutableAnnotation), enabling resource “transmutation” scenarios (e.g., turning a container into an executable) and adds coverage + a playground sample.

Changes:

  • Introduces TryGetProjectAnnotation / TryGetExecutableAnnotation and model enumerators for “project-like” and “executable-like” resources.
  • Updates DCP/orchestrator/publishing/launch-profile logic to rely on annotations rather than resource CLR types.
  • Adds new tests for annotation-driven executable/project creation and a new “ResourceSubstitution” playground sample.

Reviewed changes

Copilot reviewed 26 out of 26 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs Adds test coverage for executable/project creation driven by annotations and mutation scenarios.
tests/Aspire.Hosting.Python.Tests/AddPythonAppTests.cs Adjusts test resource discovery to avoid relying on executable resource filtering.
src/Shared/LaunchProfiles/LaunchProfileExtensions.cs Generalizes launch profile selection to IResource + project metadata annotations.
src/Aspire.Hosting/Publishing/ResourceContainerImageManager.cs Switches project detection for image builds to project metadata annotations.
src/Aspire.Hosting/Publishing/ManifestPublishingContext.cs Uses project metadata annotation helper for manifest project publishing.
src/Aspire.Hosting/Orchestrator/ApplicationOrchestrator.cs Updates “own lifetime” detection to use project/executable annotations.
src/Aspire.Hosting/ExecutableResourceExtensions.cs Adds executable-annotation helpers and enumerator for annotated executables.
src/Aspire.Hosting/Dcp/ResourceSnapshotBuilder.cs Derives project details from project metadata annotations when building snapshots.
src/Aspire.Hosting/Dcp/ExecutableCreator.cs Prepares executables/projects using annotation-based resource discovery.
src/Aspire.Hosting/Dcp/DcpNameGenerator.cs Populates DCP instance names based on project/executable annotations.
src/Aspire.Hosting/Dcp/DcpExecutor.cs Uses project annotation presence to classify executable resources as “Project” vs “Executable”.
src/Aspire.Hosting/BuiltInDistributedApplicationEventSubscriptionHandlers.cs Initializes DCP annotations using annotation-based resource enumeration.
src/Aspire.Hosting/ApplicationModel/ResourceExtensions.cs Updates resource type classification and project endpoint tracking to rely on annotations.
src/Aspire.Hosting/ApplicationModel/ProjectResourceExtensions.cs Adds TryGetProjectAnnotation and enumerator for project-annotated resources.
src/Aspire.Hosting/ApplicationModel/ProjectResource.cs Uses project annotation helper for debugger string.
src/Aspire.Hosting/ApplicationModel/CommandsConfigurationExtensions.cs Gates rebuild/restart messaging and rebuild command on project annotations.
playground/ResourceSubstitution/aspire.config.json Adds config for new ResourceSubstitution playground.
playground/ResourceSubstitution/ResourceSubstitution.AppHost/ResourceSubstitution.AppHost.csproj Adds new app host project for resource substitution demo.
playground/ResourceSubstitution/ResourceSubstitution.AppHost/Properties/launchSettings.json Adds launch profiles for the ResourceSubstitution app host.
playground/ResourceSubstitution/ResourceSubstitution.AppHost/Extensions.cs Adds sample “RunAsContainer/RunAsProject/RunAsTool” transmutation helpers.
playground/ResourceSubstitution/ResourceSubstitution.AppHost/AppHost.cs Demonstrates running the same app as project/container/tool variations.
playground/ResourceSubstitution/ResourceSubstitution.Api/ResourceSubstitution.Api.csproj Adds API project used by the ResourceSubstitution playground.
playground/ResourceSubstitution/ResourceSubstitution.Api/Properties/launchSettings.json Launch settings for the sample API.
playground/ResourceSubstitution/ResourceSubstitution.Api/Program.cs Minimal API for displaying runtime identity info.
playground/ResourceSubstitution/.vscode/launch.json Adds VS Code launch config for the playground app host.
Aspire.slnx Includes the new ResourceSubstitution playground projects in the solution.

Comment on lines +1758 to +1780
internal static string GetResourceType(this IResource resource)
{
if (resource.TryGetProjectAnnotation(out _))
{
return KnownResourceTypes.Project;
}

if (resource.TryGetLastAnnotation<ContainerImageAnnotation>(out _))
{
return KnownResourceTypes.Container;
}

if (resource.TryGetAnnotationsOfType<DotnetToolAnnotation>(out _))
{
return KnownResourceTypes.Tool;
}

if (resource.TryGetExecutableAnnotation(out _))
{
return resource is ContainerExecutableResource
? KnownResourceTypes.ContainerExec
: KnownResourceTypes.Executable;
}
Comment thread src/Aspire.Hosting/Dcp/ExecutableCreator.cs Outdated
Comment thread src/Aspire.Hosting/Dcp/ExecutableCreator.cs
Comment on lines +50 to +57
internal static LaunchProfile? GetLaunchProfile(this IResource resource, string launchProfileName, bool throwIfNotFound = false)
{
var profiles = projectResource.GetLaunchSettings()?.Profiles;
if (!resource.TryGetLastAnnotation<IProjectMetadata>(out var projectMetadata))
{
return null;
}

var profiles = projectMetadata.GetLaunchSettings(resource.Name)?.Profiles;

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No can do - TryGetProjectAnnotation is internal to Aspire.Hosting, but this file is source shared with some other projects.

Comment thread src/Aspire.Hosting/ApplicationModel/ProjectResourceExtensions.cs Outdated
@davidfowl

Copy link
Copy Markdown
Contributor

@afscrome can you make this a draft?

@afscrome

afscrome commented Jun 9, 2026

Copy link
Copy Markdown
Collaborator Author

@davidfowl I want to get CI running on this to validate - if I mark as draft, you have to manually kick off CI runs (which I dont' have permissions to do).

I'll mark as draft once I've got what I need from CI if that's OK with you..

@afscrome afscrome marked this pull request as draft June 9, 2026 18:15
@davidfowl

Copy link
Copy Markdown
Contributor

Thats OK I dont think we would merge this anyways. This is a big enough change that it should just be in the drafts.

@afscrome

afscrome commented Jun 9, 2026

Copy link
Copy Markdown
Collaborator Author

Turns out I lied, looks like CI is run for draft pull requests, so I can leave it as draft.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants