CoconutSharp.Aspire.Hosting
1.0.1
dotnet add package CoconutSharp.Aspire.Hosting --version 1.0.1
NuGet\Install-Package CoconutSharp.Aspire.Hosting -Version 1.0.1
<PackageReference Include="CoconutSharp.Aspire.Hosting" Version="1.0.1" />
<PackageVersion Include="CoconutSharp.Aspire.Hosting" Version="1.0.1" />
<PackageReference Include="CoconutSharp.Aspire.Hosting" />
paket add CoconutSharp.Aspire.Hosting --version 1.0.1
#r "nuget: CoconutSharp.Aspire.Hosting, 1.0.1"
#:package CoconutSharp.Aspire.Hosting@1.0.1
#addin nuget:?package=CoconutSharp.Aspire.Hosting&version=1.0.1
#tool nuget:?package=CoconutSharp.Aspire.Hosting&version=1.0.1
CoconutSharp Aspire
An opinionated application and deployment layer on top of .NET Aspire.
CoconutSharpApplication
↓
.NET Aspire
↓
CoconutSharp Aspire extensions
↓
Run locally with Aspire
Publish with CoconutSharp
What it is
CoconutSharp Aspire is a thin, composable layer over Aspire's application builder. It preserves
Aspire's application model, resource model, service discovery, local orchestration, dashboard and
APIs — CoconutSharpApplication.CreateBuilder("MyApp", args) returns a full
IDistributedApplicationBuilder, so every normal Aspire API keeps working unchanged.
On top of that it adds:
- A clean Run vs Publish separation (
OnRun/OnPublish). - Publish environments with free-form names, each carrying its own infrastructure profile (subscription, resource group, region, resource naming, identity).
- Local defaults: emulators with persistent development data, automatically.
- A first-class identity model (managed identity, external connection strings, Key Vault secrets).
- Existing Azure resource consumption per environment.
- First-class CloudLogin and CDM integrations for Angry Monkey applications.
- A MAUI client integration for secure mobile access.
Why it exists
Angry Monkey applications need one application model that:
- runs locally with zero cloud dependencies,
- deploys to Staging on the existing infrastructure with the existing connection strings,
- deploys to Production with managed identity,
without duplicating configuration, without leaking secrets into source, and without giving up any part of Aspire.
Relationship with Aspire
CoconutSharp does not fork Aspire, does not re-model resources, and does not replace service discovery, orchestration, the dashboard, or publishing. Everything CoconutSharp does is expressed through native Aspire mechanisms:
| CoconutSharp API | Aspire mechanism underneath |
|---|---|
OnRun(...) |
ExecutionContext.IsRunMode |
OnPublish(...) |
ExecutionContext.IsPublishMode |
| local storage defaults | RunAsEmulator + data volume + persistent container lifetime |
| local Cosmos defaults | RunAsPreviewEmulator + data volume + persistent container lifetime |
env.UseResourceGroup(...) / UseLocation(...) |
AddAzureEnvironment + WithResourceGroup / WithLocation (and the Azure:* configuration keys) |
generated names ({app}-{logical-name}-{aspire-id}) and WithResourceName(...) |
an InfrastructureResolver in AzureProvisioningOptions.ProvisioningBuildOptions — Azure.Provisioning's own naming pipeline |
builder.AddManagedIdentity(...) |
AddAzureUserAssignedIdentity (one resource per name) |
env.UseConnectionString(...) |
secret parameter (AddParameterFromConfiguration) + AddConnectionString + connection-string redirection + ExcludeFromManifest |
env.UseManagedIdentity(...) |
Aspire's default identity/role-assignment model, AddAzureUserAssignedIdentity, WithAzureUserAssignedIdentity |
publish.UseAzureAppService(...) |
AddAzureAppServiceEnvironment + WithComputeEnvironment + PublishAsAzureAppServiceWebsite |
env.UseSlot(...) |
WithDeploymentSlot on the App Service environment |
publish.UseAzureContainerApp(...) |
AddAzureContainerAppEnvironment + PublishAsAzureContainerApp |
Run vs Publish
Run (aspire run)
Aspire handles local execution.
CoconutSharp provides sensible local defaults (emulators, persistent dev data).
OnPublish configuration is never applied.
Publish (aspire publish / aspire deploy)
CoconutSharp handles deployment configuration per environment.
Aspire remains the underlying application and deployment model.
OnRun configuration is never applied.
Local execution is not an environment. Dev is a real cloud environment, exactly like
Staging and Production — never a name for aspire run.
Select the environment when publishing — Aspire's native CLI option is the primary way:
aspire publish -e Staging
Also supported: the --coconut-environment <name> AppHost argument
(aspire publish -- --coconut-environment Staging, wins over -e), the
CoconutSharp:Environment configuration key (environment variable
CoconutSharp__Environment), and CoconutSharpOptions.PublishEnvironment in code.
If environments are declared but none is selected, publishing fails with a clear error.
(Note: the Aspire CLI defaults -e to Production when omitted.)
Environments
Environment names are free-form and none is reserved. An application declares whatever names
it uses — Dev, Test, UAT, Staging, Live, prod-eu-west, anything — and CoconutSharp
attaches no behavior to any particular name. Matching is case-insensitive.
Local execution (aspire run) is never an environment.
Environment profiles
Infrastructure-wide defaults belong to the environment, not to every resource:
builder.OnPublish(publish =>
{
publish.Environment("UAT", env => env
.UseSubscription("00000000-0000-0000-0000-000000000000")
.UseResourceGroup("my-app-uat")
.UseLocation("westeurope")
.UseManagedIdentity("my-app-uat-identity"));
publish.Environment("Live", env => env
.UseResourceGroup("my-app")
.UseLocation("westeurope")
.UseManagedIdentity("my-app-live-identity"));
});
Only the selected environment's profile is ever created, so nothing from another environment can reach the output.
Anything shared by every environment is declared once, on the publish context itself, and an environment overrides only what genuinely differs:
builder.OnPublish(publish =>
{
publish.UseSubscription("00000000-0000-0000-0000-000000000000");
publish.UseLocation(AzureLocation.WestEurope);
publish.UseAppServicePlan("SharedPlan", isLinux: false, resourceGroup: "plans-rg");
publish.Environment("UAT", env => env.UseResourceGroup("my-app-uat"));
publish.Environment("Live", env => env.UseResourceGroup("my-app"));
});
Every environment-level setting has an application-level counterpart: UseSubscription,
UseResourceGroup, UseLocation, PromptForLocation, UseAppServicePlan,
UseManagedIdentity.
The environment always wins, and the order of the two blocks does not matter — write the shared
defaults before or after the environments and the result is identical. UseLocation and
PromptForLocation count as the same choice, so an environment that prompts for its region is not
overwritten by a default one.
Environments can be named by an enum instead of a string, so the set is declared once and a typo is a compile error rather than an environment that silently never matches:
public enum MyEnvironment { Dev, UAT, Live }
publish.Environment(MyEnvironment.UAT, env => env.UseResourceGroup("my-app-uat"));
api.OnPublish(publish => publish.Environment(MyEnvironment.Live, env => env.UseProductionSlot()));
The enum member's name is the environment name, so it must match what -e and the launcher use —
which they will, since the launcher reads the list from the AppHost.
Regions
A region can be given as text, as a well-known value, or asked for at publish time:
env.UseLocation("westeurope"); // text
env.UseLocation(AzureLocation.WestEurope); // well-known value, discoverable in IntelliSense
env.PromptForLocation(CoconutAzureLocations.Europe); // dropdown, asked for when publishing
Azure regions are not a C# enum in the Azure SDK — AzureLocation is a struct with a well-known
value per region, which keeps every region usable while still listing them in IntelliSense. The
text form is validated against the regions the SDK knows, so a typo fails immediately with a
suggestion ('euwest' is not an Azure region … Did you mean westus, ukwest, westeurope?) instead
of failing later in Azure. Pass validate: false for a region newer than the referenced SDK.
PromptForLocation models the region as an Aspire parameter with an InputType.Choice input, so
the publish pipeline asks for it with a dropdown and remembers the answer in the deployment state.
Which values can be dropdowns? Anything resolved after the application model is built — regions, resource groups, subscriptions, connection strings — via Aspire parameters with
Choiceinputs. The environment itself can be a dropdown too, through CoconutSharp's deferred selection (CoconutSharpOptions.PromptForEnvironment, below): environment-scoped configuration is recorded instead of executed, and a pipeline step prompts for the environment before applying it.
Resource naming
The application name is required — a positional parameter on CreateBuilder, so a publish can
never reach Azure.Provisioning without one. It is the stem of every generated Azure resource name:
var builder = CoconutSharpApplication.CreateBuilder("Contoso", args);
Resources are declared with logical (Aspire) names; the generated Azure name is
{app}-{logical-name}-{aspire-id}, lowercase:
var cosmos = builder.AddAzureCosmosDB("cosmos"); // → contoso-cosmos-x7k2p
The logical name is the identifier inside the AppHost — references and service discovery keep using it; the resource name is what the resource is actually called in Azure.
The Aspire ID is five lowercase base-36 characters derived deterministically from the application and environment names: stable across publishes (deployments stay idempotent), and different per environment (globally unique names never collide across environments). Its usage is resource-aware, not universal:
- Globally unique resources keep it — managed identity
contoso-mi-x7k2p, Key Vaultcontoso-vault-x7k2p, Cosmoscontoso-cosmos-x7k2p, storagecontosostoragex7k2p. - App Service sites don't need it: Azure already keeps their hostname unique, so a site is
contoso-weband its slotcontoso-web-staging. When an ID does apply to a slotted name, the slot goes before the ID:contoso-web-staging-x7k2p.
Generated names are automatically sanitized and truncated to each Azure resource type's naming restrictions (a storage account drops the hyphens; truncation always preserves the ID).
An exact name replaces the generated one:
storage.WithResourceName("myappstorage"); // exact Azure name
storage.WithResourceName("myappstorage", appendId: true); // exact name + Aspire ID
// or per environment:
storage.OnPublish(publish => publish
.Environment("Live", env => env.WithResourceName("myappstorage")));
Per-environment application name
An environment can adjust the application name itself, which then feeds every generated name for that environment:
builder.OnPublish(publish =>
{
// Contoso + "dev" -> every generated name starts with "contoso-dev-"
publish.Environment("Dev", env => env.UseAppNameSuffix("dev"));
// A prefix works the same way, prepended instead:
publish.Environment("UAT", env => env.UseAppNamePrefix("acme"));
// Or replace it outright:
publish.Environment("Staging", env => env.UseAppName("contoso-staging"));
});
var cosmos = builder.AddAzureCosmosDB("cosmos");
// Dev -> contoso-dev-cosmos-x7k2p
// UAT -> acme-contoso-cosmos-x7k2p
// Staging -> contoso-staging-cosmos-x7k2p
UseAppNamePrefix/UseAppNameSuffix insert the separating dash for you; only the selected
environment's call ever runs, so this never affects another environment's names.
Create or reuse
Every resource is create-or-reuse: if the named resource already exists it is used, otherwise
it is created — there is no separate "existing" declaration to keep in sync with reality. The one
way to bypass provisioning entirely is UseConnectionString(...), which points the environment at
externally supplied infrastructure by secret connection string.
Azure App Service publish targets
publish.UseAzureAppService(...) puts a project on Azure App Service:
var api = builder
.AddProject<Projects.Api>("api")
.OnPublish(publish =>
{
publish.UseAzureAppService("my-api"); // optional explicit site name
publish.Environment("UAT", env => env.UseSlot("uat"));
publish.Environment("Live", env => env.UseProductionSlot());
});
One App Service environment per application, shared by every project. The first call to
UseAzureAppService(...) creates the App Service environment resource (app-service-env); every
other project that also calls UseAzureAppService(...) reuses that same environment instead of
provisioning its own. Projects can still mix hosting targets freely — one project on App Service,
another on UseAzureContainerApp(...) — CoconutSharp just keeps one shared instance per target type.
CoconutSharp never creates the App Service Plan or its Container Registry. Both must already exist — reference them explicitly:
publish.Environment("UAT", env => env
.UseExistingAppServiceEnvironment("my-plan", "myacrregistry", resourceGroup: "my-shared-rg"));
Call this from the same environment-profile block that sets the resource group and location
(before any project's UseAzureAppService(...) — the environment is created lazily on first use,
so the reference must be in place first). Omit it entirely to be asked instead: the plan and
registry names become ordinary Aspire parameters with no default, so an interactive publish
prompts for them, and a non-interactive one fails naming exactly which configuration key is
missing (Parameters:coconut-app-service-plan / Parameters:coconut-app-service-registry) —
answerable via dotnet user-secrets set, an environment variable, or the CLI's own parameter
argument, same as any other unresolved Aspire parameter.
The CoconutSharp.Aspire.Launcher launcher answers that prompt for you. Before the final "deploy?"
confirmation, a real deploy lists the App Service Plans and Container Registries that already
exist in the current subscription (az appservice plan list / az acr list) and lets the operator
pick one instead of typing a name:
Existing Azure App Service Plans in the current subscription:
1) my-plan [Linux, westeurope, resource group my-shared-rg]
Select 1-1 (blank to leave it to Aspire's own prompt):
The pick only fills in a parameter that would otherwise be unresolved — an explicit
UseExistingAppServiceEnvironment(...) value in the AppHost is never overridden. Nothing to pick
from (Azure CLI unavailable, not signed in, or the subscription genuinely has none) silently skips
the question; set CoconutSharpLauncherOptions.SelectAppServiceResources = false to disable it
outright.
Azure Container Registry names are a global DNS namespace (
<name>.azurecr.io) — not scoped to your subscription. If two unrelated environments (or two people testing independently) both let CoconutSharp's naming policy pick the same auto-generated name, whichever deploys second getsAlreadyInUse. Existing-only references sidestep this entirely: you name a real registry you already control.
Slots are how an environment picks its App Service destination. A deployment slot is an
implementation detail of App Service, not a CoconutSharp environment — Dev, UAT, Live stay
the abstraction the rest of the model (resource group, naming, identity) is built around:
env.UseSlot("uat")deploys that environment to a named slot on the shared App Service environment (env.WithDeploymentSlotunderneath).env.UseProductionSlot()deploys that environment to the site itself — no slot is created; the production slot is the App Service site.
Switching where a publish lands is switching the environment, exactly as in Run vs Publish — nothing else changes per App Service target:
aspire publish -e UAT # deploys to the "uat" slot on the shared app-service-env
aspire publish -e Live # deploys to the site itself (production slot)
Because the slot, resource group, region, resource naming and identity are all recorded per
environment on the same env => env.... callback, selecting the environment is the single switch
that moves the whole deployment — slot included — from one target to another. Nothing needs to be
edited between publishes; only the -e <name> (or --coconut-environment, the
CoconutSharp:Environment configuration key, CoconutSharpOptions.PublishEnvironment, or the
PromptForEnvironment dropdown below) changes.
Azure App Service without containers
UseAzureAppService(...) above is always container-based — Aspire's own Azure App Service
integration builds a Linux container image and runs it via App Service Site Containers, with no
supported way to opt out. Some environments genuinely cannot use that (an existing Windows App
Service Plan, for instance, cannot run Site Containers at all). UseAppService(...) is
a completely independent target for exactly that case: dotnet publish + zip-deploy onto the site,
with no Docker and no Container Registry anywhere.
publish.Environment("Staging", env => env
.UseResourceGroup("MyApp")
.UseAppServicePlan("SharedPlan", isLinux: false, resourceGroup: "plans-rg"));
var api = builder.AddProject<Projects.Api>("api")
.OnPublish(publish =>
{
publish.UseAppService("MyApiSite");
publish.Environment("Staging", env => env.UseSlot("staging"));
});
- The Plan follows the same rule as the container path: CoconutSharp never creates it — every
environment must reference one that already exists.
isLinuxis required (not inferred): Windows and Linux App Service spell the same runtime stack differently, and CoconutSharp cannot determine which it is at AppHost-build time without a live Azure call. A plan shared from a central resource group is the normal case, hence the separateresourceGroup:. - An existing site is never wholesale rewritten. This target deploys through the Azure CLI
rather than ARM/Bicep, deliberately: an ARM deployment of
Microsoft.Web/sitessends a fullPUT, so a template describing only the properties CoconutSharp models resets the ones it does not — custom domain bindings, certificates, slot configuration. Those are never touched. What CoconutSharp does own — application settings and the site configuration below — is brought in line with the AppHost on every deploy. A site that does not exist yet is created on the referenced Plan. Which of the two will happen is shown in the pre-deploy summary before you confirm. - Slots work the same as on the container target.
env.UseSlot("staging")deploys that environment into the site's named slot (az webapp deploy --slot),env.UseProductionSlot()into the site itself. On the classic target this no longer touches Aspire's shared App Service environment at all — which matters, because creating that environment is what pulls in a Container Registry. - Deploy-only. The publish + zip-deploy step is anchored to
deploy, soaspire runand a plainaspire publishnever push code anywhere.
A project on the classic target and a project on the container-based target can coexist in the same application; each is entirely independent.
Configuration, slots and swaps
Everything the AppHost declares as an environment variable — Aspire's own WithEnvironment,
CoconutSharp's CloudLogin/CDM wiring, env.WithEnvironmentVariable(...), connection strings — is
projected onto the site as App Service application settings. The container target gets this for free
(Aspire bakes it into the generated Bicep); the classic target applies it through the Azure CLI.
Before the confirmation, the launcher shows exactly what would change:
Configuration changes
MyApiSite (staging slot)
add CloudLogin:Authority
update Cosmos:ConnectionString
update Worker process: 32-bit -> 64-bit
update Runtime stack: v8.0 -> v10.0
update WebSockets: Off -> On
remove OLD_MANUAL_SETTING
(2 Azure-managed, left alone)
Every line says outright whether the setting is being added, updated or removed, grouped in that order so the deletions read last. Otherwise deliberately terse: the preview is meant to be scanned in a glance, not read as a report — a setting's exact name (or, for site configuration, its old and new value) is what the operator needs to approve a change, and nothing more per line earns its keep.
and then asks what to do — update settings and deploy, deploy only, update settings only, settings then swap, or swap only. Configuration and code are separable because they change and fail independently; a swap is a release decision rather than a build one. When nothing differs it says so, and a settings-only run takes seconds rather than minutes.
- Application-setting values are never shown, logged, or written to disk. Settings routinely carry connection strings, so the AppHost sends the launcher only a SHA-256 fingerprint of each value — enough to tell "same" from "different" and nothing more. Error output redacts them too. Site-configuration values are shown in full, deliberately: a worker bitness or a TLS floor is not a credential, and a fingerprint would tell you nothing about a change you are approving.
- Settings are slot-sticky by default (Azure's "deployment slot setting"), so a swap leaves them
behind. A slot's configuration describes that slot — a staging connection string riding a swap
into production is exactly the accident this prevents. Opt out per setting with
env.WithSwappableSettings("Name")for values that identify the application rather than the slot. - Only what differs is written, since an unnecessary settings write restarts the site.
- A setting the AppHost does not declare is removed, so what ends up on the site is what the
AppHost says is on it. Three exceptions, all visible in the preview rather than silent:
- settings Azure owns (
WEBSITE_*,SCM_*,APPINSIGHTS_*,DIAGNOSTICS_*, and the rest) — the platform injects these itself, or the zip-deploy moments later does, so removing one either breaks the site or is undone within the same deploy; - settings named in
env.PreserveSettings("NAME", ...)— the escape hatch for a value that genuinely belongs to something else and is deliberately not modelled in the AppHost; - settings the AppHost declares but could not resolve (below) — "no value to write" is not the same as "should not exist", and the site keeps what it has.
- settings Azure owns (
- Values only a running application could supply are skipped, with a warning naming the setting —
a reference to another resource's endpoint has no meaning at deploy time. The site keeps whatever
it already has for them. Give it a literal for that environment with
WithEnvironmentVariable(...)if it should be set.
Site configuration
Beyond application settings, CoconutSharp manages the site's own configuration. Every property resolves through four tiers, highest first:
| Tier | Source | What it settles |
|---|---|---|
| 1 | AppHost, set explicitly | anything, including properties CoconutSharp otherwise ignores |
| 2 | The project | runtime stack (from its target framework); WebSockets and session affinity when its hosting model requires them — an interactive Blazor Server or SignalR host keeps per-client state on the instance serving it, so neither is a preference |
| 3 | CoconutSharp defaults | 64-bit worker; Always On off |
| 4 | Untouched | everything else — FTPS, TLS floor, HTTP/2, HTTPS-only, health check |
publish.UseAppService("MyApiSite") // runtime derived from the project's <TargetFramework>
.UseAlwaysOn() // override a CoconutSharp default
.UseFtpsState(CoconutFtpsStates.FtpsOnly); // opt a tier-4 property into management
publish.Environment("Staging", env => env.Use32BitWorker()); // per environment
Tier 4 is genuinely invisible: CoconutSharp does not read, compare, report or write those properties, so whatever Azure defaults them to survives a deploy untouched. Setting one in the AppHost promotes it to tier 1, and it is managed from then on.
The runtime version no longer has to be repeated in the AppHost — the project's <TargetFramework>
already states it. Pass runtimeVersion: explicitly to override, or when the project's target
framework is not what the site should run.
The CoconutSharp publish target
Instead of mapping every project by hand, the application can declare CoconutSharp itself as the publish target — an application-level deployment target based on Azure App Service (it has nothing to do with Azure Container Apps):
builder.OnPublish(publish => publish.UseAzureAppServiceAsDefault());
aspire runis untouched. The target only exists during publish/deploy; locally the application is a completely normal Aspire application.- Configured resources are mapped automatically. Every compute resource without an explicit
hosting platform is mapped to the CoconutSharp target (the shared
app-service-env). A project that declares its own target —UseAzureAppService(...),UseAzureContainerApp(...)or native AspireWithComputeEnvironment(...)— always keeps it, so hybrid applications keep working. - Or selected at publish time. With
publish.UseAzureAppServiceAsDefault(o => o.PromptForResourceSelection = true)the publish pipeline shows a checkbox list of the not-explicitly-mapped compute resources; deselected resources are left out of that deployment. Non-interactive sessions deploy everything.
Publishing from Visual Studio
Visual Studio's built-in Aspire flow only offers Azure Container Apps for Aspire; the
CoconutSharp target ships as publish profiles instead (a first-class entry beside the built-in
one would require a Visual Studio extension). Add one profile per environment under
Properties/PublishProfiles/ — the Publish window's profile list then is the environment
picker:
Properties/PublishProfiles/CoconutSharp-Test.pubxml
Properties/PublishProfiles/CoconutSharp-UAT.pubxml
Properties/PublishProfiles/CoconutSharp-Live.pubxml
<Project>
<PropertyGroup>
<PublishProtocol>FileSystem</PublishProtocol>
<PublishUrl>obj\coconutsharp-publish\</PublishUrl>
<CoconutSharpTarget>true</CoconutSharpTarget>
<CoconutEnvironment>UAT</CoconutEnvironment>
<CoconutSharpAspireCommand>deploy</CoconutSharpAspireCommand>
</PropertyGroup>
</Project>
Selecting CoconutSharp-UAT and publishing hands the AppHost to the Aspire CLI
(aspire deploy -e UAT), which runs the CoconutSharp pipeline: resource mapping, then App Service
deployment. The same profiles work from the command line:
dotnet publish -p:PublishProfile=CoconutSharp-UAT
| Property | Purpose |
|---|---|
CoconutEnvironment |
Required. The environment to deploy (-e). |
CoconutSharpAspireCommand |
deploy (default) or publish — artifacts only, no Azure. |
CoconutSharpDryRun |
true lists the pipeline steps without executing them. |
CoconutSharpOutputPath |
Artifact/deployment output path (-o). |
CoconutSharpInteractive |
true allows CLI prompts (off by default — see below). |
CoconutSharpNoBuild |
true (default) reuses the build MSBuild just did. |
CoconutSharpAspireExe |
Aspire CLI path. Default aspire. |
Trying it without deploying anything. Two safe modes, both worth running before a first real deployment:
dotnet publish -p:PublishProfile=CoconutSharp-UAT -p:CoconutSharpDryRun=true
lists the pipeline steps (you should see coconut-target among them), and
dotnet publish -p:PublishProfile=CoconutSharp-UAT -p:CoconutSharpAspireCommand=publish
runs the real pipeline but stops at generated Bicep — the web.bicep it writes should contain
Microsoft.Web/sites, never Microsoft.App/containerApps.
Profile publishing is deliberately non-interactive. MSBuild redirects the console, so the CLI
cannot render prompts; the target passes --non-interactive rather than letting a prompt hang the
build. That is why the environment lives in the profile, and why PromptForResourceSelection
belongs to the terminal workflow (aspire deploy) rather than the Publish window — from Visual
Studio, resources are mapped automatically.
The MSBuild plumbing ships in the NuGet package (buildTransitive), so package consumers get the
CoconutSharp publish target automatically.
App Service serves external endpoints only. A project deployed to the CoconutSharp target must expose one —
builder.AddProject<...>("web").WithExternalHttpEndpoints(). Without it the pipeline fails with "The endpoint 'http' on resource 'web' is not external." This is an Azure App Service constraint, not a CoconutSharp one.
The CoconutSharp launcher
A publish profile cannot ask questions — MSBuild captures the console, so the target passes
--non-interactive. The launcher exists for the interactive case: a small console application
that asks what to do, then hands off to the Aspire CLI.
return await CoconutSharpLauncher.RunAsync(args);
That is the whole launcher, and nothing is passed to it.
The AppHost comes from the project file. A launcher says which application it publishes by referencing it, the way it would reference anything else:
<ItemGroup>
<ProjectReference Include="..\MyApp.AppHost\MyApp.AppHost.csproj" ReferenceOutputAssembly="false" />
</ItemGroup>
The launcher's MSBuild targets find that reference at build time — an AppHost is recognized from
its project file, so nothing marks it as one — and record where it is in the launcher's own
assembly, which is where RunAsync reads it back. A path string in code says the same thing until
somebody moves a folder, at which point it says it at run time, in a console, to whoever was trying
to deploy; a reference is checked by the build and follows a rename on its own.
ReferenceOutputAssembly="false" is required, and the build says so if it is missing. The launcher
drives the AppHost through the Aspire CLI as a separate process and never loads its assembly —
that separate process with an inherited console is exactly what keeps the interactive prompts
working. Referencing the output would pull the whole Aspire hosting graph into a console that
deliberately has no Aspire dependency.
The targets ship in the NuGet package (build/buildTransitive), so a PackageReference to
CoconutSharp.Aspire.Launcher gets them automatically. A launcher that takes the launcher as a
project reference imports them by hand instead, since build/*.targets only flow from a package:
<Import Project="..\..\src\CoconutSharp.Aspire.Launcher\build\CoconutSharp.Aspire.Launcher.targets" />
Two AppHost references make the launcher ask which one, rather than guess: name it with
options.AppHostProjectName, or keep the others out with CoconutSharpAppHost="false" metadata on
the reference. options.AppHostPath still overrides everything, and --apphost <path> overrides it
for a single run.
The environments come from the AppHost — the launcher runs it once with CoconutSharp's own
argument, which makes it report the environments it declares and exit without building or running
anything. There is no second list to keep in sync, and no way for the two to disagree. Set
options.Environments explicitly only to offer a subset.
Running it opens a menu straight away:
Coconut Sharp
Account someone@example.com
Subscription My Subscription
7ab27abd-… (current az login)
------------------------------------------------------------
Up/Down to choose, Enter to confirm.
> Run locally (Aspire)
Azure sign out — signed in as someone@example.com
Reading environments from the AppHost…
The menu opens before it knows everything. Asking the AppHost which environments it declares means building and running it, which is several seconds the operator would otherwise spend looking at a blank screen. So the launcher starts that in the background and draws the menu with what it already has: the local run, the Azure session action, and one dimmed row standing in for the environments. When the AppHost answers, that row is replaced in place by the environments, and the menu carries on:
> Run locally (Aspire)
Azure sign out — signed in as someone@example.com
Deploy to Test
Deploy to UAT
Deploy to Live
The Azure row is always there, because every row below it needs a signed-in Azure CLI and
nothing else in the launcher can fix that. Signed out, it reads Azure sign in — nobody is signed in and runs az login on this console — browser and device-code flows render and can be answered,
exactly as they do during a real deploy. Signed in, it offers az logout. Neither is a launch: the
menu reopens afterwards with the new account, in the header and in the row.
Who is signed in is read once, before the menu, and shown in the header from the first screen — so a launcher opened on a signed-out machine says so rather than failing several steps later, when something finally asks Azure for a resource.
An AppHost that cannot be read is a dimmed row saying so, not a dead launcher: a local run and the
Azure session actions do not need the environment list. Naming an environment outright
(-e UAT) does need it, and still waits and fails with the reason.
It skips the menu when told what to do, so it works in CI:
coconut-publish -e UAT --yes
| Argument | Effect |
|---|---|
-e / --environment <name> |
Skip the menu and use this environment. |
--local |
Run the AppHost locally under Aspire. |
--artifacts |
Generate deployment artifacts instead of deploying. |
--dry-run |
List the pipeline steps; change nothing. |
-o / --output <path> |
Artifact output path. |
-y / --yes |
Skip the deployment confirmation. |
Anything the launcher does not recognise is forwarded to the Aspire CLI unchanged, so it never becomes a bottleneck on CLI features. A real deploy is confirmed before it runs; a dry run and a local run are not, because they change nothing.
The launcher deliberately does not reference Aspire or the AppHost. It decides what to run; the Aspire CLI hosts the application and the pipeline. That is what keeps the prompts working, and it is why the same library can back a Visual Studio extension later — see the extension design.
The pre-deploy summary
Before a real deploy runs (never for a dry run, artifacts-only run, or local run), the launcher shows exactly what is about to happen:
Deployment plan — Production
Subscription : My Subscription [00000000-0000-0000-0000-000000000000] (ambient — current az login)
Resource group : contoso
Region : uksouth
Resource Kind Status
----------------------------- ------------------------- ------------------------------------
cosmos -> contosoproperties AzureCosmosDB ALREADY DEPLOYED (will update)
login -> contoso-login Project NEW
contoso-mi-x7k2p AzureUserAssignedIdentity ALREADY DEPLOYED (will update)
Deploy to 'Production'? This changes Azure infrastructure. [y/N]:
Every row is sourced from somewhere that can actually be trusted, never guessed:
- Resource group, region, and each resource's classification come straight from CoconutSharp's
own model (
UseConnectionString→EXTERNAL, everything else is CoconutSharp-managed) via an artifacts-onlyaspire publishthe launcher runs on your behalf — neveraspire deploy, so building the summary never touches Azure by itself. - Each resource's exact deployed name is read back out of the Bicep that same publish generates, rather than recomputed — CoconutSharp's naming policy only produces the name it hands to Azure.Provisioning; the resource type can sanitize it further (a storage account silently drops hyphens), so the generated template is the only fully reliable source.
- Subscription and live existence (
NEWvs.ALREADY DEPLOYED) come from the Azure CLI (az account show,az group show,az resource list) when it is installed and logged in. Every one of these calls degrades toUNKNOWNon failure — no CLI, not logged in, no network, timeout — rather than blocking the deploy or guessing.
Set options.ShowDeploymentSummary = false to skip it (it costs an extra aspire publish
round-trip); otherwise it runs automatically, including with --yes for CI, so the log still
records exactly what was about to happen.
Choosing the environment with a dropdown
var builder = CoconutSharpApplication.CreateBuilder(args, options => options.PromptForEnvironment = true);
With PromptForEnvironment, an interactive aspire publish shows the declared environments as a
dropdown instead of requiring -e up front. When -e was passed it is preselected; the CLI only
forwards an environment when -e is given, so a plain aspire publish simply highlights the
first declared environment:
Select environment
Environment: [ Staging ▾ Production ]
How it works: environment-scoped configuration (Environment(name, …) callbacks) is recorded
instead of executed, and a coconut-environment pipeline step — ordered before parameter
processing and Azure resource preparation — prompts for the environment and then applies exactly
the selected environment's configuration. The published output is identical to passing -e
explicitly (verified byte-for-byte).
Nothing changes where a prompt is impossible or unwanted:
--non-interactivesessions (CI) use the CLI-resolved environment exactly as before, and fail with the list of choices when none was passed or it matches no declared environment - so CI must keep passing-e <name>(with--non-interactive), exactly as it did before.- An explicitly chosen environment —
CoconutSharpOptions.PublishEnvironment,--coconut-environment, or theCoconutSharp:Environmentconfiguration key — is never prompted over. - Local runs are unaffected; prompting is publish-only.
Identity
Identity is first class and configurable per environment — at the environment level, applied to every compute resource that does not declare its own:
builder.OnPublish(publish =>
{
publish.Environment("Dev", env => env.UseManagedIdentity("my-app-dev-identity"));
publish.Environment("Live", env => env.UseManagedIdentity("my-app-live-identity"));
});
or per resource:
api.OnPublish(publish => publish.Environment("Live", env => env.UseManagedIdentity("api-identity")));
One identity per name
builder.AddManagedIdentity(); // generated name: {app}-mi-{aspire-id}
builder.AddManagedIdentity("my-app-identity"); // exact name; created when missing, reused when present
AddManagedIdentity returns the same resource for the same name however often it is called, and
an explicitly named identity is deployed under exactly that name — so publishing an environment
repeatedly reuses one identity and never produces duplicates.
Strategies
Supported strategies (CoconutIdentity):
- Managed identity — preferred for production. Without a name, Aspire's default model applies (each compute resource gets an identity with the role assignments its referenced resources need). With a name, a shared user-assigned identity is attached.
- Connection string —
env.UseConnectionString("Some:Config:Key"). The value comes from secure external configuration (user secrets, pipeline variables) at publish time; it is never stored in source. The Azure resource is not provisioned in that environment. - Key Vault secret —
env.WithEnvironmentSecret("CDM:Storage:ConnectionString", vault, "name"). The app reaches the vault with its identity; the secret value never appears in configuration. - Azure credential — endpoint-only configuration; the Azure SDK credential chain authenticates.
Client applications never receive storage keys, Cosmos keys or other server credentials.
Azure resources
Aspire's own integrations are the API — AddAzureStorage, AddAzureCosmosDB,
AddAzureKeyVault, AddAzureServiceBus, … CoconutSharp adds behavior, not new resource types:
- Locally, storage runs as Azurite and Cosmos DB as the local emulator automatically, with
named volumes and persistent container lifetimes, so restarting the AppHost keeps your data.
Override per resource with
OnRun(run => run.UsePersistentStorage("name") / UseEphemeralStorage() / DisableLocalDefaults()), or app-wide viaCoconutSharpOptions. - Per environment, choose
WithResourceName(...)(exact Azure name, create-or-reuse),UseConnectionString(...)(externally-supplied value) orUseManagedIdentity(). - The generic strategies (
WithResourceName,UseConnectionString,UseManagedIdentity,WithEnvironmentSecret) work with any Aspire Azure provisioning resource — Service Bus, Event Hubs, Redis, PostgreSQL, Key Vault, … — because they compose Aspire's own naming and parameter facilities.
Where components keep their data
CoconutSharp does not decide this, and holds no mapping for any component. A component that needs a
database or a file store ships its own Aspire integration package, and an AppHost points it at an
account the way it points any Aspire resource at another — WithReference, paired with WaitFor:
var cosmos = builder.AddAzureCosmosDB("cosmos");
var storage = builder.AddAzureStorage("storage");
var login = builder.AddCloudLogin<Projects.My_Login>("login");
login
.WithReference(cosmos)
.WithReference(storage)
.WaitFor(cosmos)
.WaitFor(storage);
Those overloads belong to the component's package, not to this one. Each does exactly what a plain Aspire reference does and then additionally maps the resource onto the configuration keys that component binds — so an account a service is never pointed at is an account it never uses, and nothing is adopted or created on its behalf.
WaitFor is Aspire's own, and it is not optional locally: a storage value is assembled from the
emulator's allocated endpoints, which exist only once the emulator is running.
What CoconutSharp still owns is what an account resolves to per environment —
WithResourceName, UseConnectionString, UseManagedIdentity. Those apply to plain Aspire
connection-string references, which is all an integration package emits, so a component obeys a
strategy it knows nothing about. That is what lets these packages stay independent of CoconutSharp
entirely.
Hold the result of
AddCloudLogin/AddCDMin avar. Each returns its own builder type, which is what carries that package'sWithReferenceoverloads; typing the variable asIResourceBuilder<ProjectResource>hides them again.
CloudLogin
// The application's own CloudLogin server project (Cosmos:* and Storage:* configuration):
var login = builder.AddCloudLogin<Projects.My_Login>("login");
// …or an already-deployed CloudLogin server:
var login = builder.AddCloudLogin("login", "https://login.example.com");
// Connect any project to it (sets LoginUrl + a normal Aspire reference):
web.WithCloudLogin(login);
CoconutSharp configures and connects the existing CloudLogin components; it never duplicates CloudLogin's authentication implementation.
CDM
var portal = builder.AddCDM<Projects.My_Portal>("portal", settings => settings.BaseName = "MyApp");
// WithServiceAccess adds the backend channel CDM's external data provider reads CloudLogin-owned
// records over. Its own call rather than a flag on WithReference: the key it grants bypasses user
// identity on CloudLogin/Service/*, so it is granted only to servers that need it.
portal.WithReference(login).WithServiceAccess(login).WaitFor(login);
portal.WithReference(cosmos).WithReference(storage).WaitFor(cosmos).WaitFor(storage);
CDM reads CDM:Cosmos:* and CDM:Storage:*, and its own Aspire package writes them. CDM is an
application dependency, not a generic Azure resource: CoconutSharp deploys what the model contains,
whoever wired it; CDM's own data access logic is untouched.
MAUI
CoconutSharp.Aspire.Client.Maui connects a MAUI client securely:
MAUI → CloudLogin authentication → access token → API → managed identity → Azure resources
builder.AddCoconutSharpClient(options =>
{
options.CallbackScheme = "myapp";
options.AddEnvironment(CoconutClientEnvironments.Local, "https://localhost:7157", env =>
{
env.ApiUrl = "https://localhost:7074";
env.PortalUrl = "https://localhost:51671";
});
options.AddEnvironment(CoconutClientEnvironments.Production, "https://login.example.com", env =>
{
env.ApiUrl = "https://app.example.com";
env.PortalUrl = "https://portal.example.com";
});
options.ActiveEnvironmentName = CoconutClientEnvironments.Production;
});
Authentication (system-browser sign-in, callback interception, secure token storage, refresh,
logout) is provided by the existing CloudLogin.Maui integration — CoconutSharp supplies the
environment-selected endpoints and wires it up. The device never receives Azure credentials.
Minimal example
Illustrative — exact APIs follow the implementation in
src/CoconutSharp.Aspire.Hosting.
var builder = CoconutSharpApplication.CreateBuilder("MyApp", args);
// Environment names are yours to choose; each carries its own infrastructure profile.
builder.OnPublish(publish =>
{
publish.Environment("UAT", env => env
.UseResourceGroup("my-app-uat")
.UseManagedIdentity("my-app-uat-identity"));
publish.Environment("Live", env => env
.UseResourceGroup("my-app")
.UseManagedIdentity("my-app-live-identity"));
});
// Logical names: the active environment decides the deployed Azure name.
var storage = builder.AddAzureStorage("storage");
var cosmos = builder.AddAzureCosmosDB("cosmos");
// Each service is pointed at what it uses, and waits for it.
var cloudLogin = builder.AddCloudLogin<Projects.Login>("login");
cloudLogin.WithReference(cosmos).WithReference(storage).WaitFor(cosmos).WaitFor(storage);
var cdm = builder.AddCDM<Projects.Portal>("portal");
cdm.WithReference(cloudLogin).WithServiceAccess(cloudLogin).WaitFor(cloudLogin);
cdm.WithReference(cosmos).WithReference(storage).WaitFor(cosmos).WaitFor(storage);
var api = builder
.AddProject<Projects.Api>("api")
.WithReference(cosmos)
.WithCloudLogin(cloudLogin)
.OnPublish(publish =>
{
publish.UseAzureAppService("my-api");
publish.Environment("UAT", env => env.UseSlot("uat"));
publish.Environment("Live", env => env.UseProductionSlot());
});
var web = builder
.AddProject<Projects.Web>("web")
.WithReference(api);
builder.Build().Run();
Packages
| Package | Purpose |
|---|---|
CoconutSharp.Aspire.Hosting |
The hosting layer (AppHost side): environments, naming, identity, Azure targets |
CoconutSharp.Aspire.Launcher |
The interactive console launcher (aspire run/deploy menu, account confirmation) |
CoconutSharp.Aspire.Client |
Client-side environment/endpoint model |
CoconutSharp.Aspire.Client.Maui |
MAUI integration (CloudLogin + environments) |
Built and verified against Aspire 13.5.1 on .NET 10.
Related projects
CoconutSharp composes existing Angry Monkey components declaratively; it does not reimplement them. Their own repositories are the source of truth for what they do and how to configure them directly:
- CloudLogin - authentication, account and
profile management.
CloudLogin.Aspire.Hostingis whatAddCloudLoginabove wires into the application model; see its Aspire integration section for the parts CoconutSharp does not change. - CDM (Cloud Data Management) - the
entity/form/view engine behind
AddCDM.CDM.Aspire.Hostingdocuments the same configuration keys this package writes.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- Aspire.Hosting (>= 13.5.3)
- Aspire.Hosting.Azure (>= 13.5.3)
- Aspire.Hosting.Azure.AppContainers (>= 13.5.3)
- Aspire.Hosting.Azure.AppService (>= 13.5.3)
- Aspire.Hosting.Azure.CosmosDB (>= 13.5.3)
- Aspire.Hosting.Azure.KeyVault (>= 13.5.3)
- Aspire.Hosting.Azure.Storage (>= 13.5.3)
- Azure.Core (>= 1.62.0)
- Azure.Provisioning.AppService (>= 1.3.1)
- CoconutSharp.Aspire.Launcher (>= 1.0.1)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.