ForgeTrust.AppSurface.Aspire
Source of truth
.NET Aspire integration for the AppSurface ecosystem.
Overview
ForgeTrust.AppSurface.Aspire provides modular local composition and an explicit adapter from an evaluated Aspire resource graph to AppSurface deployment intent. Local profiles remain available, while native AppHosts can attach artifact-only publish and read-only verification steps to Aspire's deployment pipeline.
Use this package when you want:
AspireApp<TModule>as the AppHost entry point.AspireProfilecommands such aslocalordemoto choose a resource graph.IAspireComponent<TResource>classes that can be injected, reused, ordered, and resolved once throughAspireStartupContext.- An explicit deployment annotation on one existing
ProjectResourceand one target-level Aspire pipeline integration.
Deployment support does not provision foundations, resolve secret values, apply infrastructure, execute jobs, or change traffic. It publishes deterministic evidence and delegates read-only parity inspection to the selected provider target. Local profiles still do not automatically forward arbitrary Aspire CLI arguments.
It also does not configure application-side OpenTelemetry exporters. Use ForgeTrust.AppSurface.Observability in each app project that should publish logs, traces, and metrics to Aspire or another OTLP collector.
For deterministic tests of profile-based AppHosts, use ForgeTrust.AppSurface.Aspire.Testing. Its configured graph-only proof shows how to inject per-call constructor inputs before profile activation and inspect annotations without starting resources. Native AppHosts that build directly from DistributedApplication.CreateBuilder(args) should continue using Aspire's native testing API.
The packed ForgeTrust.AppSurface.Aspire.Testing dependency graph promotes MessagePack to a minimum of 2.5.303 to avoid GHSA-qhrr-8q5h-9q3h. Consumer central package overrides must preserve this floor; keep NuGet audit enabled for ongoing checks of the resolved graph.
For files stored in the source repository, see Repository-relative assets for the root-selection contract and a component example.
Release Guidance
This AppHost-oriented package follows the coordinated AppSurface release policy. Before using a prerelease build in an AppHost, development, or test environment, check the package chooser and release hub for publication status, compatibility guidance, and readiness.
Installation
dotnet add package ForgeTrust.AppSurface.AspireWorking Example
The repository includes a local Aspire AppHost proof at examples/aspire-apphost. It composes the existing web example through two Aspire components so the resource graph is not just a direct AddProject call.
From the repository root:
dotnet build examples/aspire-apphost/AspireAppHostExample.csproj
If the Aspire CLI is installed, run the local profile:
aspire run --apphost examples/aspire-apphost/AspireAppHostExample.csproj -- local
The -- local token selects the AppSurface AspireProfile command. Aspire opens the dashboard and shows the web project resource.
The web example registers ForgeTrust.AppSurface.Observability, so hitting the web app should produce app-side telemetry in the Aspire dashboard when Aspire supplies OTEL_EXPORTER_OTLP_ENDPOINT.
Repository-relative assets
Use AspireStartupContext.GetPathFromRoot(relativePath) when a component needs a trusted asset stored in the source repository, such as an import file or a directory mounted into a resource. The context snapshots its root once when constructed: by default it searches upward from builder.AppHostDirectory and selects the nearest ancestor containing a .git directory or a regular .git file. A .git file is treated as a discovery marker; its contents are not read and Git is not invoked. Nested repositories therefore use the nearest marker. The AppHost directory is the search anchor, not automatically the repository root.
An explicit applicationRoot takes precedence over discovery. It must be nonblank and fully qualified; it is normalized to a full path but does not need to exist. Validation happens before discovery. If discovery has no usable root (including missing metadata or a missing/nonabsolute AppHost anchor), context construction and path-free components remain usable; the first GetPathFromRoot call throws an actionable InvalidOperationException directing the caller to repository metadata or an explicit root. The helper computes a path only: the component must check that its file or directory exists and report an actionable asset-specific error. It preserves Path.Combine behavior for empty, rooted, and .. inputs and does not enforce containment, so supply trusted repository-relative paths.
For a component in a normal profile or factory, recover from missing metadata by running it from a valid AppHost checkout. There is no profile, CLI, or factory root option. An explicit root is available only to a caller that constructs and owns an AspireStartupContext directly. Project-local files that should travel with the AppHost should instead be resolved from Aspire's builder.AppHostDirectory.
Prerequisites are the .NET SDK, the existing AppHost project using the Aspire AppHost SDK, and Git checkout metadata (a .git directory or file) for default discovery. Add the package to the AppHost project with the existing command:
dotnet add package ForgeTrust.AppSurface.Aspire
The following component resolves the exact realm file and theme directory used by the pinned Skoolit consumer. Given a checkout rooted at /work/skoolit, the expected absolute paths are /work/skoolit/src/Skoolit.AppHost/keycloak/skoolit-realm.json and /work/skoolit/src/Skoolit.KeycloakTheme/theme/skoolit. Check each asset separately before returning the parameter/resource, and do so during graph generation before the AppHost builds or starts containers:
using System.IO;
using Aspire.Hosting;
using Aspire.Hosting.ApplicationModel;
using ForgeTrust.AppSurface.Aspire;
public sealed class KeycloakAssetsComponent : IAspireComponent<ParameterResource>
{
public IResourceBuilder<ParameterResource> Generate(
AspireStartupContext context,
IDistributedApplicationBuilder appBuilder)
{
var realmPath = context.GetPathFromRoot(
"src/Skoolit.AppHost/keycloak/skoolit-realm.json");
var themePath = context.GetPathFromRoot(
"src/Skoolit.KeycloakTheme/theme/skoolit");
if (!File.Exists(realmPath))
{
throw new FileNotFoundException(
$"Keycloak realm file was not found at '{realmPath}'. Check the repository checkout and asset path.",
realmPath);
}
if (!Directory.Exists(themePath))
{
throw new DirectoryNotFoundException(
$"Keycloak theme directory was not found at '{themePath}'. Check the repository checkout and asset path.");
}
return appBuilder.AddParameter("keycloak-realm-path", realmPath);
}
}
The returned parameter illustrates a valid resource shape; a consumer can instead use these already-validated paths to configure its existing resource. Path resolution is independent of process working directory and executable output location, but it does not create or validate the assets for you.
Minimal Shape
Use AspireApp<TModule> to start your AppHost:
using AspireAppHostExample;
using ForgeTrust.AppSurface.Aspire;
await AspireApp<ExampleModule>.RunAsync(args);
Define a profile command. Constructor-inject concrete components and return the top-level component from GetComponents():
using CliFx.Binding;
using ForgeTrust.AppSurface.Aspire;
using Microsoft.Extensions.Logging;
[Command("local", Description = "Run the local Aspire graph.")]
public sealed partial class LocalProfile : AspireProfile
{
private readonly WebAppEnvironmentComponent _webApp;
public LocalProfile(WebAppEnvironmentComponent webApp, ILogger<LocalProfile> logger)
: base(logger)
{
_webApp = webApp;
}
public override IEnumerable<IAspireComponent> GetComponents()
{
yield return _webApp;
}
}
Define a project component:
using Aspire.Hosting;
using Aspire.Hosting.ApplicationModel;
using ForgeTrust.AppSurface.Aspire;
public sealed class WebAppProjectComponent : IAspireComponent<ProjectResource>
{
public IResourceBuilder<ProjectResource> Generate(
AspireStartupContext context,
IDistributedApplicationBuilder appBuilder) =>
appBuilder.AddProject<Projects.WebAppExample>("web");
}
Define a second component that depends on the first one. Resolve generates the dependency only once for the same component instance:
using Aspire.Hosting;
using Aspire.Hosting.ApplicationModel;
using ForgeTrust.AppSurface.Aspire;
public sealed class WebAppEnvironmentComponent : IAspireComponent<ProjectResource>
{
private readonly WebAppProjectComponent _webApp;
public WebAppEnvironmentComponent(WebAppProjectComponent webApp)
{
_webApp = webApp;
}
public IResourceBuilder<ProjectResource> Generate(
AspireStartupContext context,
IDistributedApplicationBuilder appBuilder) =>
context.Resolve(_webApp)
.WithEnvironment("APPSURFACE_ASPIRE_EXAMPLE", "local");
}Dependency Security
The repository centrally pins MessagePack to 2.5.303 for Aspire's transitive StreamJsonRpc dependency. This is the patched 2.x release for GHSA-qhrr-8q5h-9q3h, which prevents nested containers from reusing trailing bytes to justify excessive allocations. Keep this security floor when updating Aspire; an Aspire update alone does not guarantee a patched MessagePack resolution. A future move to MessagePack 3.x requires 3.1.9 or later for the same fix. Regenerate the affected NuGet lock files, including the linux-x64 and osx-arm64 AppHost variants, after changing the pin.
AppHost Project References
Aspire AppHost projects treat most ProjectReference entries as resources and generate typed classes in the Projects namespace. Keep the AppSurface library reference out of the resource graph and leave the app project as a resource:
<Project Sdk="Microsoft.NET.Sdk">
<Sdk Name="Aspire.AppHost.Sdk" Version="13.4.4" />
<ItemGroup>
<ProjectReference Include="../../Aspire/ForgeTrust.AppSurface.Aspire/ForgeTrust.AppSurface.Aspire.csproj" IsAspireProjectResource="false" />
<ProjectReference Include="../web-app/WebAppExample.csproj" AspireProjectMetadataTypeName="WebAppExample" />
</ItemGroup>
</Project>Argument Forwarding
AspireProfile.PassThroughArgs defaults to an empty array:
public override string[] PassThroughArgs => [];
Override it only when a profile intentionally passes known AppHost arguments into DistributedApplicationBuilder. AppSurface command parsing still owns the profile command, so unknown command-line arguments are not forwarded automatically.
For example, aspire run --apphost examples/aspire-apphost/AspireAppHostExample.csproj -- local selects the local AppSurface profile. It is not a deployment command and does not make arbitrary Aspire deployment arguments pass through the profile.
Native Deployment Pipeline
Deployment AppHosts must preserve Aspire's original arguments. Call DistributedApplication.CreateBuilder(args) directly; do not route aspire publish or aspire do through the local AspireProfile/CliFx command path.
This preview supports the coordinated Aspire AppHost SDK, Aspire.Hosting, and Aspire CLI 13.4.4 patch line. The deployment-pipeline APIs are experimental and isolated inside the package adapter. Use the matching CLI for publish and named-step proof; advancing Aspire requires a package release with compile, packed-consumer, --list-steps, publish, and targeted do verification before consumers upgrade.
var builder = DistributedApplication.CreateBuilder(args);
var bindings = builder.AddParameter("appsurface-gcp-bindings");
var image = builder.AddParameter("migration-image");
var revision = builder.AddParameter("appsurface-source-revision");
var connection = builder.AddParameter("database", secret: true);
var providerTarget = GcpCloudRunDeploymentTarget.Create();
var target = builder.AddAppSurfaceDeploymentTarget(
"gcp-staging",
providerTarget,
bindings,
revision);
builder.AddProject<Projects.Migrations>("migrations")
.WithAppSurfaceMigrationJob(options => options
.WithImage(image)
.WithPhase(DeploymentPhase.CandidatePreparation)
.WithCommand("dotnet", "/app/Migrations.dll")
.WithConnectionSecret(connection, "ConnectionStrings__database")
.RequirePrivateNetwork()
.WithExecutionPolicy(1, 1, 0, TimeSpan.FromMinutes(10)))
.WithComputeEnvironment(target);
await builder.Build().RunAsync();
GcpCloudRunDeploymentTarget comes from ForgeTrust.AppSurface.Deployment.GcpCloudRun and remains Aspire-independent; the Aspire adapter owns the non-secret binding-path and source-revision parameters. The binding path must be relative to and remain beneath builder.AppHostDirectory. The connection parameter is checked for secret classification and retained only by logical name; its value is never evaluated.
aspire publish --environment Staging --output-path ./artifacts/appsurface
aspire do appsurface-gcp-verify --environment Staging --output-path ./artifacts/appsurface
The artifact step is required by Aspire's standard Publish aggregation step. The named verification step depends on artifact publication, so targeted verification regenerates the same complete bundle before read-only inspection. Ordinary publish never runs verification.
The native appsurface-gcp-verify step always uses Shadow parity because it is the pre-cutover adoption proof. After state import and the single-writer cutover, the application-owned release workflow can call IDeploymentTarget.VerifyAsync with Owned parity to require AppSurface provenance labels; the native step does not infer that authority change.
Pitfalls:
- Every annotated migration project must be assigned with
WithComputeEnvironment; AppSurface does not guess a target. - Image and source parameters must be non-secret, canonical evidence. Images must include the full repository and SHA-256 digest, and source revisions must be full lowercase commits.
- Output must be an empty directory or an AppSurface-owned directory containing exactly the generated bundle. Unexpected or non-owned files are rejected.
aspire publishmakes no cloud calls and changes no infrastructure. CI remains responsible for credentials, state, apply, job execution, canaries, approval, and promotion.appsurface-gcp-verifyis intentionally shadow-only. Do not treat it as proof that the generated writer is authoritative after cutover.
Discovery Rules
AppSurface discovers concrete IAspireComponent classes from the AppHost entry assembly and registers each concrete type as a singleton. It does not register components by interface and does not discover components from every referenced assembly.
For reusable package-owned components, expose them through an app-local profile or module path until broader discovery support exists.
Troubleshooting
| What you see | Likely cause | Fix |
|---|---|---|
aspire: command not found |
The Aspire CLI is not installed or not on PATH. |
Install the Aspire CLI, then rerun the aspire run --apphost ... -- local command. |
Aspire stops at Checking certificates... or Trusting certificates... |
The local ASP.NET Core development certificate has not been trusted yet. | Run aspire certs trust from an interactive shell, or fall back to dotnet dev-certs https --trust, and approve the OS trust prompt. |
The type or namespace name 'Projects' could not be found |
The AppHost SDK did not generate project metadata, or the project reference is marked as IsAspireProjectResource=false. |
Build the AppHost project and keep IsAspireProjectResource=false only on library references such as ForgeTrust.AppSurface.Aspire. |
| Duplicate resource name error | Two different component instances generated the same Aspire resource name. | Constructor-inject shared concrete components and resolve dependencies through AspireStartupContext.Resolve(...) instead of manually creating components. |
| Unknown deployment argument does not reach Aspire | The AppHost routed deployment through the local AspireProfile/CliFx command path. |
Use a native deployment entry point that calls DistributedApplication.CreateBuilder(args); keep profiles for local compatibility. |
DistributedApplicationTestingBuilder.CreateAsync<TEntryPoint>() reports that the AppHost exited without building |
The AppHost builds only after asynchronous AppSurface/CliFx profile dispatch. | Select the profile by type with ForgeTrust.AppSurface.Aspire.Testing instead of invoking the entry point. |
Pitfalls
- Keep component classes in the AppHost assembly when relying on automatic discovery.
- Resolve component dependencies through
AspireStartupContextso the same component instance is generated once. - Use stable Aspire resource names such as
web; duplicate names fail in Aspire's own resource model. - Keep Aspire in charge of environment selection, pipeline ordering, publish, and named steps; AppSurface supplies the intent/compiler adapter rather than a second deployment command hierarchy.