[API Proposal] Resource projections: typed, target-scoped container views instead of implicit shape conversion
- Dominant language
- C#
- Stars
- 6.3k
- Forks
- 991
- Avg merge
- 2d 15h
- Merged PRs (30d)
- 196
Description
## Background and Motivation
Aspire establishes a resource's *execution shape* — whether it runs as a container, as a local process, or is not launched at all — through three mechanisms that disagree with one another:
- **CLR type.** `ContainerResource`, `ExecutableResource`, `ProjectResource`.
- **Annotation presence.** Much of the runtime treats a `ContainerImageAnnotation` on *any* resource as "this is a container", regardless of the resource's type.
- **Model replacement.** `PublishAsDockerFile` removes the executable or project from the model and substitutes a private container resource. Azure `RunAsContainer` removes the Azure resource and adds a local container under the same name, retaining an internal back-reference.
Because of the second mechanism, a resource can be *classified* as a container while its static type says otherwise. This has two consequences. Container-only APIs constrain `T` to `ContainerResource`, so they are unavailable on exactly the resources that have acquired container behavior. And a shape change can happen implicitly, with no compiler error and no diagnostic.
Today there is no typed API for publishing a .NET project as a prebuilt image. The nearest available mechanism is attaching a container annotation to a resource that is not a container:
```csharp
builder.AddProject("service")
.WithAnnotation(new ContainerImageAnnotation
{
Image = "contoso/service",
Tag = "latest"
});
```
Nothing in this code states whether the change applies to local development, to deployment, or to both. Nothing prevents the project from being treated as a container in run mode, where the author almost certainly still wants it to run locally as a process. The static type is still `ProjectResource`, so `WithVolume` and other container APIs remain unavailable even though parts of the runtime now classify the resource as a container.
The third mechanism carries a different cost: replacing a resource object breaks the reference identity used by events, notifications, waits, endpoints, and parent relationships. The `PublishAsDockerFile` implementation's own source comments acknowledge that references to the original resource may remain dangling. Emulator surrogates such as `AzureCosmosDBEmulatorResource` work around the same problem by having a container-derived object forward to another resource's annotation collection, so the object a callback receives is not the object the rest of the runtime knows about.
### What is proposed
**Resource projections**: typed, target-specific views of one logical resource.
The original resource — the **owner** — remains the sole member of `IResourceCollection` and keeps the stable logical identity. A **projection** is a real `ContainerResource`-derived object, configured through a callback, that is never independently added to the model. Explicit `RunAs*` and `PublishAs*` APIs create projections, so a shape change is a typed operation rather than an inference from annotation presence, and nothing is removed from the model.
When a runtime projection is selected it becomes the exclusive source for DCP model generation; the owner supplies logical identity and inherited configuration. If no projection applies, DCP realizes the owner exactly as it does today.
This unifies two patterns that are separate today: a local container standing in for a publish-only remote resource (`RunAsContainer`), and a container realization of an executable or project (`PublishAsDockerFile`).
## Proposed API
Types are illustrative where noted; the concrete shape of the selector and the kind discriminator is an open question below, and some of it may be satisfied by work already underway in #18052.
```diff
namespace Aspire.Hosting.ApplicationModel;
+// A typed, non-model-member view of an owner resource for a particular execution target.
+public interface IResourceProjection : IResource
+ where TOwner : IResource
+{
+ // The resource this projection projects. Always a member of IResourceCollection.
+ TOwner Owner { get; }
+
+ // Decides which operation or target environment this projection applies to.
+ // Illustrative: must not permanently encode a Run/Publish boolean (see #8984).
+ ResourceProjectionSelector Selector { get; }
+
+ // The execution shape this projection realizes.
+ ResourceKind Kind { get; }
+}
```
Container projection APIs, expressed on the owner builder so that the AppHost variable keeps its original type:
```diff
namespace Aspire.Hosting;
public static class ContainerResourceBuilderExtensions
{
+ // Container source is the owner type's own default strategy.
+ // Only offered by resource types that define one.
+ public static IResourceBuilder RunAsContainer(this IResourceBuilder builder, Action>? configure = null) where T : IResource;
+ public static IResourceBuilder PublishAsContainer(this IResourceBuilder builder, Action>? configure = null) where T : IResource;
+ // Prebuilt image. The image is required so a projection can never exist without a valid container source.
+ public static IResourceBuilder RunAsContainerImage(this IResourceBuilder builder, string image, Action>? configure = null) where T : IResource;
+ public static IResourceBuilder PublishAsContainerImage(this IResourceBuilder builder, string image, Action>? configure = null) where T : IResource;
+ // Existing Dockerfile.
+ public static IResourceBuilder RunAsDockerFile(this IResourceBuilder builder, string contextPath, string? dockerfilePath = null, Action>? configure = null) where T : IResource;
+ // Dynamically generated Dockerfile, using the existing Dockerfile builder APIs.
+ public static IResourceBuilder RunAsDockerFile(this IResourceBuilder builder, string contextPath, Action dockerfile, Action>? configure = null) where T : IResource;
}
```
Model inspection. `GetOwnerOrSelf` is deliberately an extension method over an annotation rather than a member on `IResource`, so that no existing interface gains a member:
```diff
namespace Aspire.Hosting;
public static class ResourceExtensions
{
+ // The owner for a projection; the resource itself otherwise.
+ public static IResource GetOwnerOrSelf(this IResource resource);
}
public static class DistributedApplicationModelExtensions
{
+ // Model members only, at every phase, ignoring projections.
+ public static IEnumerable GetResourceOwners(this DistributedApplicationModel model);
+ // Effective resources for a target the process is not currently executing,
+ // e.g. previewing publish output during a run.
+ public static IEnumerable GetEffectiveResources(this DistributedApplicationModel model, ResourceExecutionContext context);
}
```
### Signatures that change meaning without changing shape
These keep their signatures and are **not** proposed for obsoletion, but their results change once projections are evaluated:
```csharp
public static IEnumerable GetContainerResources(this DistributedApplicationModel model);
public static bool IsContainer(this IResource resource);
```
This follows from a deliberate decision described below: **after the projection evaluation phase, the model returns effective resources by default.** Existing callers — including the first-party `BeforeStartEvent` subscribers that bulk-configure containers — become correct without modification.
### Relationship to existing `RunAsContainer` and `PublishAsDockerFile`
Both names already exist and are more specific than the proposed generic forms, so they continue to bind:
- Azure `RunAsContainer` overloads are declared on concrete owner types and hand back a concrete container type, for example `IResourceBuilder` → `Action>?`. The intent is to reimplement these on top of projections **without changing their signatures**.
- `PublishAsDockerFile where T : ExecutableResource` and `where T : ProjectResource` likewise keep their signatures and are reimplemented to stop removing the owner from the model.
## Usage Examples
Publishing a project as a prebuilt image, with container APIs legally available inside the callback and no effect on local run:
```csharp
builder.AddProject("service")
.PublishAsContainerImage("contoso/service:latest", container =>
{
container.WithVolume("data", "/var/data");
});
```
Running an executable as a container locally while publishing it normally:
```csharp
builder.AddExecutable("worker", "./worker", ".")
.RunAsContainerImage("contoso/worker:dev", container =>
{
container.WithHttpEndpoint();
});
```
Generating a development container for a project without an existing Dockerfile:
```csharp
builder.AddProject("service")
.RunAsDockerFile(
contextPath: ".",
dockerfile => dockerfile
.From("mcr.microsoft.com/dotnet/aspnet:10.0")
.Copy("publish", "/app")
.Entrypoint("dotnet", "Service.dll"),
container => container.WithHttpEndpoint());
```
Run-mode and publish-mode configuration are isolated by construction, without `if (builder.ExecutionContext.IsRunMode)` blocks:
```csharp
builder.AddProject("service")
.RunAsContainerImage("contoso/service:dev", c => c.WithVolume("devdata", "/var/data"))
.PublishAsContainerImage("contoso/service:latest");
```
## Alternative Designs
**Single method with a container-source argument.** One `RunAsContainer` / `PublishAsContainer` pair taking a source object instead of six methods. Fewer names, but the source argument becomes a discriminated union and the required-ness of the image argument is lost. Listed as an open question rather than settled.
**Annotation-based classification only.** Continue inferring shape from annotations, but make the infrastructure consistent about it — broadly the direction of #18052. This removes the type-versus-annotation disagreement but not the mode-leakage or discoverability problems: an annotation attached to an owner still cannot distinguish run from publish, and container APIs still are not offered on the owner builder. The two efforts are complementary rather than competing; see the comparison comment below.
**Keep resource replacement, fix the dangling references.** Rejected because the unique-name invariant means the owner must be removed for the replacement to be added, so identity is necessarily broken for anything holding a reference from before the call. Ordering of configuration relative to the `RunAs*` call also stays significant.
**Opt-in projection resolution** (`GetEffectiveResource(context)` at each call site) rather than projected-by-default. Rejected on failure-mode grounds. A consumer that forgets the call operates on the wrong shape *silently*: a bulk operation over containers simply skips projected containers, no error is raised, and the resource still starts, just unconfigured. Under projected-by-default the symmetric mistake — forgetting `GetOwnerOrSelf` — fails loudly, because `ResourceNotificationService.PublishUpdateAsync` already throws on reference inequality and notifications, logging, and eventing are the identity-sensitive consumers. It also matches expected frequency: after evaluation nearly every consumer wants the effective shape.
**Threading an execution context through every resolution call.** Unnecessary, and it would force a context parameter into APIs that do not carry one — `BeforeStartEvent` exposes only services and the model. A process runs in exactly one operation, and although a model may span several compute environments, each resource resolves to exactly one; `GetDeploymentTargetAnnotation` already throws when a resource has more than one deployment target and no environment is specified. Selection can therefore be resolved once and recorded.
## Risks
Restricted here to risks that bear on the API shape. Implementation risks are in the follow-up comments.
- **Two objects now exist per logical resource.** Identity canonicalization must be complete across events, notifications, waits, references, endpoints, parent relationships, and dashboard snapshots. Projected-by-default is chosen specifically so that a missed conversion fails loudly rather than silently.
- **Phase-dependent model semantics.** The same `DistributedApplicationModel` instance answers kind queries differently before and after the evaluation phase. Tests that construct a model without running the phase will see owners. The boundary must be observable and documented rather than being an implicit behavior change.
- **`IsContainer` and `GetContainerResources` change behavior in place.** This is what makes existing callers correct without edits, but it is a behavioral change to stable APIs and must be called out in release notes.
- **Deferred callbacks move failures away from their cause.** Evaluating projection callbacks late is what makes configuration order-independent, but exceptions then surface far from the AppHost line that registered them. Registration sites must be captured for diagnostics. For retained APIs such as `PublishAsDockerFile` and Azure `RunAsContainer`, this also changes eager evaluation to late evaluation — usually the desired fix, but observable.
- **Multiple projections need deterministic selection.** Ambiguous target policy must produce a conflict diagnostic rather than an arbitrary winner.
- **Polyglot exports.** The existing `PublishAsDockerFile` overloads carry `[AspireExport]`, so the projection APIs must be expressible through ATS or the unsupported subset must be intentional and documented.
## Open questions that block committing the shape
1. **`DockerFile` or `Dockerfile`?** The existing `PublishAsDockerFile` uses `DockerFile`, but `DockerfileBuildAnnotation`, `WithDockerfile`, and `WithDockerfileBuilder` all use `Dockerfile`. This should be settled before the family is committed.
2. **Is six methods the right size**, or should the container source be an argument to a single `RunAsContainer` / `PublishAsContainer` pair?
3. **What is projection selection keyed by** — operation, compute environment, publisher, or a general policy object? #8984 asks for multiple projections of the same kind selected by target policy, so the contract should not assume one projection per kind even if the initial public API exposes only run and publish.
Further open questions concerning annotation inheritance, evaluation timing, and compute-environment interaction are in the implementation comment below.
---
*This summarizes a longer design document. Three follow-up comments cover the comparison with #18052, compatibility and release sequencing, and the implementation model.*
Contributor guide
Research direction
Start by reading the existing RunAsContainer and PublishAsDockerFile implementations, then trace IResourceCollection, projection evaluation, and the GetContainerResources and IsContainer callers. Compare the proposal with #18052 and inspect the identity-sensitive ResourceNotificationService and BeforeStartEvent paths. Done means an agreed projection design with stable owners, target-isolated resources, preserved existing signatures, and coverage for run and publish behavior.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- csharp
- Domain
- backend-api-design, devtools
- Issue type
- Feature
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 38/100