Allow IAspireStore paths to be rooted in a durable, AppHost-scoped location
- Dominant language
- C#
- Stars
- 6.3k
- Forks
- 991
- Avg merge
- 2d 15h
- Merged PRs (30d)
- 196
Description
## Background and Motivation
`IAspireStore` is currently rooted in the AppHost's MSBuild intermediate output directory:
```
/obj/.aspire/
```
That location is deliberately disposable. The `IAspireStore` doc comment says so explicitly — the `.aspire` prefix exists so "the folder can be deleted without impacting unrelated files" — and everything currently stored there is regenerable (DCP executable certificates, content-hashed file copies via `GetFileNameWithContent`).
The problem is that not everything a hosting integration wants to put in the store is regenerable. Some of it is **durable user data**, and `obj/` is destroyed routinely by `dotnet clean`, `git clean -xdf`, and IDE "Clean Solution".
### Motivating example: portable volume paths (#19404)
PR #19404 adds a convention that lets projects and executables participate in persistent volumes by dereferencing a path from an environment variable, so the same code works in inner loop and after deployment:
```csharp
app.WithPersistentVolume(data, "/srv/data", env: "DATA_PATH");
```
In publish/deploy mode `DATA_PATH` is `/srv/data`, backed by a PVC. In local run mode it resolves to a deterministic host directory allocated from `IAspireStore`:
```
/obj/.aspire/volumes//
```
This produces two problems:
1. **The data is not disposable.** It is whatever the developer's app wrote — an uploaded file, a seeded SQLite database, generated content. A routine `dotnet clean` silently destroys it, which is not what "persistent volume" implies to anyone.
2. **It is asymmetric with containers.** A container consumer of the same persistent volume gets a real Docker named volume, which survives clean, rebuild, and machine restart. A project consumer of that same logical volume does not. Same API, same declared volume, materially different durability guarantee depending on the resource type.
`obj/` is not arbitrary, to be fair — it gives worktree isolation for free, since each worktree has its own `obj`. Any replacement root has to preserve that property.
## Proposed API
Add a root selector to `IAspireStore`, leaving current behavior as the default.
```diff
namespace Aspire.Hosting.ApplicationModel;
+public enum AspireStoreRoot
+{
+ /// AppHost intermediate output (obj). Disposable; contents must be regenerable.
+ Intermediate = 0,
+
+ /// User profile storage scoped to the AppHost path. Survives clean and rebuild.
+ Durable = 1,
+}
public interface IAspireStore
{
string BasePath { get; }
string GetFileNameWithContent(string filenameTemplate, System.IO.Stream contentStream);
+ /// Gets a deterministic path beneath the specified root.
+ string GetPath(AspireStoreRoot root, params string[] segments)
+ => Path.Combine([Path.GetFullPath(BasePath), .. segments]);
}
```
Notes on the shape:
- `BasePath` is unchanged and remains exactly equivalent to `GetPath(AspireStoreRoot.Intermediate)`. No existing caller changes behavior.
- `GetPath` is a **default interface method**. `IAspireStore` is shipped public API and is not `[Experimental]`, so a plain interface member would be a breaking change for external implementers. The default falls back to `BasePath`, so third-party implementations keep compiling and simply do not gain durable storage until they opt in.
- There is no `GetPath(params string[])` overload without a root. Choosing a lifetime should be a deliberate act at the call site.
### Durable root layout
The durable root reuses the existing deployment-state convention rather than inventing a new one. `DistributedApplicationBuilder.LoadDeploymentState` already persists to:
```
~/.aspire/deployments//.json
```
where `appHostPathSha` is `SHA256(AppHostPath.ToLowerInvariant())`, already computed during builder construction and published to configuration as `AppHost:PathSha256`. Being keyed on the *full path* is what makes it worktree-safe.
The durable store root would follow the same pattern:
```
~/.aspire/store//
```
`AspireStore` can read `AppHost:PathSha256` from `IConfiguration` exactly as it already reads `Aspire:Store:Path`, with a matching `Aspire:Store:DurablePath` escape hatch (`ASPIRE__STORE__DURABLEPATH`) for redirection in tests and CI.
## Usage Examples
Durable data — survives `dotnet clean`:
```csharp
var volumeDir = store.GetPath(AspireStoreRoot.Durable, "volumes", resourceHash, volumeHash);
// ~/.aspire/store/01F474DF.../volumes/8b1a9953c4611296/2f8a4c31d0b7e5a9
```
Regenerable data — stays in `obj`, unchanged from today:
```csharp
var certDir = store.GetPath(AspireStoreRoot.Intermediate, "dcp", "executables", name, "certificates");
// /obj/.aspire/dcp/executables/api/certificates
```
Two worktrees of the same repo stay isolated, because the AppHost path differs:
```
/Users/mitch/code/aspire/playground/TestShop/TestShop.AppHost/TestShop.AppHost.csproj
-> ~/.aspire/store/01F474DF9C7E333B05D3DE4DD7E0569ED11A72C0F745E2B39EE86787D8CDD44A/
/Users/mitch/code/worktrees/feature-x/playground/TestShop/TestShop.AppHost/TestShop.AppHost.csproj
-> ~/.aspire/store/D9363E4E013EF1FCA369C23DD1F98CB2E68F8A87C2E1A09B4375D3D090CE8D2B/
```
In #19404 this reduces `VolumeMountPathResolver.GetPathUnderStore` to a single call, replacing hand-rolled composition over `store.BasePath`:
```diff
-return Path.GetFullPath(Path.Combine([Path.GetFullPath(store.BasePath), .. pathSegments]));
+return store.GetPath(AspireStoreRoot.Durable, pathSegments);
```
## Alternative Designs
**Location-based enum names (`Project` / `User`) instead of lifetime-based (`Intermediate` / `Durable`).** Location names are more concrete, but they describe where the bytes land rather than the guarantee the caller depends on — and the guarantee is the reason to choose. Worth settling in review. `Intermediate` also carries a mild ambiguity: it matches MSBuild's "intermediate output path" precisely, but reads oddly next to the AppHost-path segment that *is* an intermediate path component.
**Extension method on `IAspireStore` instead of an interface member.** Non-breaking with no DIM needed, but an extension cannot see the durable root — the store would still need to expose it somehow, so this just moves the interface change rather than avoiding it.
**A separate `IAspireDurableStore` service.** Cleanly avoids touching the shipped interface, but forces every consumer to take a second dependency and splits one concept across two abstractions.
**Move the whole store out of `obj`.** Simplest to describe, but it silently changes where existing regenerable data lives, gives up free worktree isolation for every current consumer, and leaves no disposable tier at all.
## Risks
- **Interface addition on shipped public API.** Mitigated by the default interface method; external implementers keep compiling and get `BasePath` semantics until they opt in.
- **Durable data is no longer cleaned by `dotnet clean`.** That is the point, but it means `~/.aspire/store` grows over time with no reclamation story. A cleanup command or documented retention guidance should be considered alongside this.
- **Path length on Windows.** A 64-character SHA segment under the user profile is long. Adopting `XxHash3` for the intermediate segment (see the open question below) reduces it to 16 characters and removes this risk outright, rather than mitigating it by truncating a SHA.
- **No migration.** Anything already written under `obj/.aspire` by a consumer that switches roots would not be moved. For #19404 this is acceptable while the feature is unreleased, but a consumer switching after shipping would strand data.
## Open question: stop using SHA256 where the hash is not cryptographic
The intermediate segment described above uses SHA256 purely because that is what the existing
`deployments` path does. That inherits a choice that conflicts with the repo's own guidance in
`AGENTS.md`:
> Do not use cryptographic hashes such as SHA-256 when the hash is not security-related. Prefer
> `System.IO.Hashing.XxHash3` when you need a stable non-cryptographic hash.
Nothing about AppHost path disambiguation is security-related. It needs determinism and a low
collision probability, not preimage or collision *resistance* against an adversary. SHA256 is being
used here as a deterministic identifier generator, and we pay for it twice: in hashing cost, and far
more visibly in 64 characters of a finite path budget.
**Recommendation for this issue:** the new durable root should use `XxHash3`, not inherit the
`deployments` choice. It is greenfield, so there is no compatibility burden, and it shortens the
intermediate segment from 64 characters to 16. Note this would make the store internally consistent,
since the caller-supplied segments in #19404 already use `XxHash3`.
### Existing usages worth auditing separately
A survey of `SHA256.` in `src/` shows a clean split. These are genuinely cryptographic and must stay:
| Location | Purpose |
| --- | --- |
| `Aspire.Cli/Agents/AspireSkills/GitHubArtifactAttestationVerifier.cs` | Artifact attestation verification |
| `Aspire.Cli/Agents/AspireSkills/AspireSkillsBundle.cs` | Verifying files against manifest hashes |
| `Aspire.Cli/DotNet/DotNetCliRunner.cs` | Content integrity |
| `Aspire.Cli/Telemetry/MachineInformationProviderBase.cs` | One-way anonymization of machine identity |
These are deterministic-identity uses and are candidates for `XxHash3`:
| Location | Purpose |
| --- | --- |
| `Aspire.Hosting/DistributedApplicationBuilder.cs` | `appHostPathSha` / `appHostProjectNameSha` |
| `Aspire.Hosting/DeveloperCertificateService.cs` | Lookup key |
| `Aspire.Cli/Projects/DotNetBasedAppHostServerProject.cs` | Path hash |
| `Aspire.Cli/Projects/PrebuiltAppHostServer.cs` | Path hash |
| `Aspire.Cli/Projects/GuestAppHostProject.cs` | Identity hash |
| `Aspire.Cli/Caching/DiskCache.cs` | Cache key |
**These are not free to change, which is why they belong in a separate issue rather than this one.**
Two specific hazards:
- `appHostPathSha` and `appHostProjectNameSha` are published to configuration as `AppHost:PathSha256`,
`AppHost:ProjectNameSha256`, and `AppHost:Sha256`. Per the comment at the computation site, the
project-name variant is consumed for Azure Functions and Azure environment *resource naming*, and the
path variant determines the on-disk deployment state location. Changing the algorithm would rename
generated Azure resources and orphan every developer's existing deployment state. That needs a
migration story, not a find-and-replace.
- `Shared/UserSecrets/UserSecretsPathHelper.cs` is excluded entirely. It must match the algorithm used
by `dotnet user-secrets` and `Microsoft.Extensions.Configuration.UserSecrets`; it is an external
compatibility contract, not our choice.
The actionable split: adopt `XxHash3` for the new durable root here, and track retrofitting the
existing identity hashes as separate work gated on a migration plan.
---
Referenced by #19404, which is the first consumer that needs the durable root.
Contributor guide
Research direction
Start with IAspireStore and AspireStore, then read DistributedApplicationBuilder.LoadDeploymentState to understand the existing AppHost path state convention. Trace VolumeMountPathResolver.GetPathUnderStore as the motivating consumer and verify that durable paths are worktree-scoped, intermediate behavior remains unchanged, and the new root uses the proposed configuration escape hatch and hash strategy.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- csharp
- Domain
- developer-experience, infrastructure
- Issue type
- Feature
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Quiet
- Clarity
- Mostly clear
- Newbie friendliness
- 48/100