KoalaSoft.Aspire.Hosting.ServiceSources
0.5.1
dotnet add package KoalaSoft.Aspire.Hosting.ServiceSources --version 0.5.1
NuGet\Install-Package KoalaSoft.Aspire.Hosting.ServiceSources -Version 0.5.1
<PackageReference Include="KoalaSoft.Aspire.Hosting.ServiceSources" Version="0.5.1" />
<PackageVersion Include="KoalaSoft.Aspire.Hosting.ServiceSources" Version="0.5.1" />
<PackageReference Include="KoalaSoft.Aspire.Hosting.ServiceSources" />
paket add KoalaSoft.Aspire.Hosting.ServiceSources --version 0.5.1
#r "nuget: KoalaSoft.Aspire.Hosting.ServiceSources, 0.5.1"
#:package KoalaSoft.Aspire.Hosting.ServiceSources@0.5.1
#addin nuget:?package=KoalaSoft.Aspire.Hosting.ServiceSources&version=0.5.1
#tool nuget:?package=KoalaSoft.Aspire.Hosting.ServiceSources&version=0.5.1
Aspire.Hosting.ServiceSources
A .NET Aspire AppHost extension that lets builder.AddService("orders") resolve to a real,
running resource whose source is chosen per developer, not baked into the AppHost.
Why
AddProject<T>() assumes a service lives in the AppHost's own solution. In a real
microservice environment, services live in separate repositories, and different developers
want different things for the same service: clone it locally to edit, run it from an
already-checked-out working copy, reach an instance already running in a shared Kubernetes
dev cluster, hit a fixed URL, or just run a published container image. The AppHost should
only describe what it depends on; where that dependency actually comes from is a
per-developer choice, made without ever touching the AppHost's .csproj/.sln.
AddService() is the seam: the AppHost calls it once per service, and a developer-local
config file decides how it's actually resolved — a managed or self-managed local git
checkout ("local"), a kubectl port-forward against a dev cluster ("kubernetes"), a
fixed, already-known URL ("url"), or a published container image run locally
("container") — behind one stable return type, so the AppHost code never has to change
when a developer switches sources.
Install
Published on nuget.org as KoalaSoft.Aspire.Hosting.ServiceSources.
If every service your AppHost declares is a .NET project, this is the only package you need:
dotnet add package KoalaSoft.Aspire.Hosting.ServiceSources
This package floors Aspire at 13.5.2, so an AppHost still on 13.4.x gets a mixed Aspire family. NuGet takes the highest floor, so
Aspire.Hostingis lifted to 13.5.2 while yourAspire.AppHost.Sdk,Aspire.Hosting.AppHostand the DCP and dashboard packages the SDK pins to it stay where they are. Nothing warns about it at restore. Move your AppHost's own Aspire version to 13.5.2 or later at the same time:<Sdk Name="Aspire.AppHost.Sdk" Version="13.5.2" />
Services that aren't .NET projects need Aspire's hosting package for their language, referenced by your AppHost alongside this one — see Non-.NET local services:
kind |
Package | Minimum |
|---|---|---|
java |
CommunityToolkit.Aspire.Hosting.Java |
13.3.0 |
javascript |
Aspire.Hosting.JavaScript |
13.5.2 |
dotnet add package KoalaSoft.Aspire.Hosting.ServiceSources
# then one line per language the AppHost actually declares a service for
dotnet add package Aspire.Hosting.JavaScript # kind: javascript
dotnet add package CommunityToolkit.Aspire.Hosting.Java # kind: java
Add one per language you actually use, and nothing for a language you don't. This package
compiles against both but declares neither as a dependency, so an AppHost with no
javascript service never sees Aspire.Hosting.JavaScript, nor the Aspire floor it carries
— which is the point, because a mixed Aspire family restores clean and then throws
TypeLoadException on first resolve. The version is yours to choose: pick any release at or
above the minimum above, and the two need not match each other.
Forget one and you are told which. Below the minimum, your build fails naming the package and
the version it resolved — SERVICESOURCES001 for Aspire.Hosting.JavaScript,
SERVICESOURCES002 for CommunityToolkit.Aspire.Hosting.Java. A prerelease of the minimum
counts as below it. Missing entirely, the first AddService() for a service of that kind fails
with a message naming the package to install — which is the only report a guest-language AppHost
gets, since the Aspire CLI hands it this package through a project reference and a project
reference imports no build-time checks.
The build-time check also fires for a package that arrived transitively, from a graph your AppHost
does not control — where neither raising it nor removing it may be yours to do. Set
ServiceSourcesSkipGuestLanguageFloorCheck=true in that project to turn the check off; the version
problem is then reported at run time, by the service that needed it.
Or reference the project directly from your AppHost instead:
<ItemGroup>
<ProjectReference Include="path/to/Aspire.Hosting.ServiceSources/Aspire.Hosting.ServiceSources.csproj" />
</ItemGroup>
Requires .NET 8 or later (net8.0, net9.0, and net10.0 are all supported) and an AppHost
project using the Aspire.AppHost.Sdk (aspire new / aspire restore sets this up).
Every release is listed in the
changelog, which
is where breaking changes and their migrations are recorded. Check it before upgrading —
while the version is below 1.0.0, a breaking change can ship in a minor release.
Preview builds
Every push to main publishes a prerelease build (0.x.y-alpha.0.N) to GitHub Packages.
Stable releases go to nuget.org only — use those unless you specifically need an unreleased
fix. Previews are pruned after each release — only the five most recent are kept — so treat
them as disposable and never pin one in a long-lived project.
GitHub's NuGet registry requires authentication for every download, even for public
packages — unlike the container registry, it has no anonymous access. This is not a grant
on this repository: any authenticated GitHub user can download a public package, so all you
need is a token on your own account. It must be a classic personal access token with the
read:packages scope; fine-grained tokens are not supported by GitHub Packages.
dotnet nuget add source https://nuget.pkg.github.com/flojon/index.json \
--name servicesources-preview --username <your-github-username> --password <your-pat>
dotnet add package KoalaSoft.Aspire.Hosting.ServiceSources --prerelease
One package, so one prerelease. The language hosting packages are Aspire's own and come from nuget.org as usual — a preview of this package does not imply a preview of those.
Getting started
1. Declare the service in Program.cs:
using Aspire.Hosting.ServiceSources;
var builder = DistributedApplication.CreateBuilder(args);
var orders = builder.AddService("orders");
var api = builder.AddProject<Projects.Api>("api")
.WithReference(orders);
builder.Build().Run();
2. Add the shared catalog, servicesources.yaml, next to the AppHost project (commit this
file):
services:
orders:
repository: https://github.com/example/orders
project: src/Orders.Api/Orders.Api.csproj
defaultRef: main # optional; branch, tag, or commit SHA
(A service that isn't a .NET project also takes a kind — see
Non-.NET local services.)
3. Add your own servicesources.local.json next to it (gitignore this file — it's
per-developer):
{
"services": {
"orders": { "source": "local" }
}
}
That file is the base layer of the AppHost's own configuration — an environment variable or an
appsettings.json entry can override any of it for a single run, without an edit. See
Overriding servicesources.local.json.
That's it — running the AppHost now clones orders into
<AppHostDirectory>/.servicesources/checkouts/orders/, checks out main, and runs it via
Aspire's own project orchestration, wired up to api through service discovery exactly like
a project reference would be.
"local" source options
Requires git (2.7 or newer) on PATH for a managed checkout — the same "a tool you already
have" trade the "kubernetes" source makes with kubectl. Every git operation runs under your own
git, so your credential helper, SSH agent, ~/.gitconfig and proxy settings apply unchanged. A
service pointed at your own directory with path needs no git at all.
{
"services": {
"orders": { "source": "local" },
"payments": {
"source": "local",
"local": { "path": "/home/dev/code/payments", "ref": "feature/new-checkout" }
}
}
}
Omit
pathfor a managed checkout: cloned once into<AppHostDirectory>/.servicesources/checkouts/<serviceName>/, and reconciled to the configuredref(or the catalog'sdefaultRef) on every run. Uncommitted edits are never discarded — if the checkout is dirty and the ref changed, resolution fails loudly instead of overwriting your work. Anything you put at that path yourself that isn't a plain clone — a linkedgit worktree, or a clone made with--separate-git-dir— is refused with an explanation rather than replaced; point at it withpathinstead. A directory there with no.gitentry at all is treated as debris from an interrupted clone and deleted, so don't hand-place a plain directory as a quick override — usepathfor that too. The.servicesources/directory gitignores itself on first use — no need to add it to your own.gitignore— and shields what it holds from your AppHost repository's build settings (see below).ref/defaultRefaccept a commit SHA, not only a branch or tag — and a SHA is how a team turns "whatever's at the tip the first time each developer clones" into a reviewed checkout, since a resolved service can build and run code the checkout's own repository controls (seeSECURITY.md). It costs a catalog edit per bump; that's the actual trade.Set
pathto point at a checkout you manage yourself (e.g. an existing local clone). It's used as-is — no clone, no checkout, no fetch, ever. A relativepathis anchored to the AppHost directory, and must name a directory that already exists.refcannot be combined withpath.Keep the file to the services you actually add — unless you use
UseDeferredCheckout(), which removes the reason to.AddService()has to hand back the real resource, so it can't wait until the AppHost has finished composing to find out which services it wants: an entry whose first checkout anAddService()call would have to block on is cloned on the first call, in parallel with the others, before the AppHost has said which ones it wants. Entries you never add cost network and disk for that first clone. The AppHost logs which ones those were at startup — and warns if one of them failed, since nothing else would ever tell you — so you know what to drop.Nothing else is speculated over. A checkout that already exists — every service on every run after the first — is resolved only for the services you add, and so is a
pathoverride. And a service whose first checkout is deferred is cloned only when you add it: a deferred registration blocks on nothing, so its clone no longer has to be started ahead of demand to run alongside the others. WithUseDeferredCheckout()on, a config listing ten"local"services in front of an AppHost that adds two downloads two (#76).Either way, only the services you actually add are reconciled to their configured
ref: a checkout that already exists is never touched on behalf of an entry you don'tAddService(), so work in progress on a branch there is safe.
prepare: a checkout that has to bootstrap itself
A managed checkout is assumed to be runnable the moment it is cloned. That holds for a dotnet
project, and for a Maven/Gradle service whose wrapper builds it as part of running — but not for a
repository whose runnable artifact or data asset is produced by a script the repository commits and
then gitignores. Such a checkout resolves cleanly and then fails, because the thing the catalog
names isn't there.
prepare is a command run inside the materialized checkout, before the kind is allowed to judge
it:
services:
routing:
repository: https://github.com/example/routing
kind: java
prepare:
command: ["./prepare.sh"]
windowsCommand: ["pwsh", "-File", "prepare.ps1"] # optional; replaces command on Windows
mode: oncePerCommit # the default | once | always | never
java:
jarPath: graphhopper-web-11.0.jar
args: ["server", "gh-config-local.yml"]
port: 8989
commandis a list, not a string. There is no shell, so there are no quoting or word-splitting rules to get wrong and an argument containing spaces needs no escaping. The first element is either a path inside the checkout (./prepare.sh,scripts/bootstrap) or a bare program name resolved throughPATH(make,npm,bash). A path is confined to the checkout: an absolute one, or one that climbs out with.., is rejected by name — the same rulejava.jarPathfollows, and for the same reason, since the catalog is shared configuration you clone rather than write.windowsCommandreplacescommandwhen the AppHost runs on Windows. It exists because one catalog is committed and shared by a team across platforms, so each value can only be spelled one way, and./prepare.shisn't executable on Windows. Leave it out for a program that exists as an executable everywhere (make,python,dotnet) — the command runs there unchanged.npmis not one of those, and it's the case to know about. There is nonpm.exe, onlynpm.cmd; nothing here goes through a shell, and Windows resolves a bare name onPATHby appending.exerather than by walkingPATHEXT. So["npm", "ci"]starts fine on Linux and macOS and fails to start on Windows, and wantswindowsCommand: ["npm.cmd", "ci"]beside it. The same goes foryarn,pnpmandtsc. The launch failure names this as the likely cause.The command runs with the checkout as its working directory, with no shell between it and the tool, and both its streams are relayed line by line as they arrive, tagged with the service. A tool that prints a progress meter floods this — a plain
curldownload, for one — since every carriage-return-terminated update becomes its own line; prefercurl -sSor--no-progress-meter(or the equivalent for whatever tool the command runs):[prepare routing] Downloading graphhopper-web-11.0.jar... [prepare routing] Importing sweden-latest.osm.pbf [prepare routing] done in 4m12sOn the run that creates the checkout under
UseDeferredCheckout()those lines are the service's own resource log, visible in the dashboard, and the service shows a Preparing state while the step runs — which is what a four-minute import needs, so that it reads as an initialization phase rather than as a hang. Every other run reports to the AppHost's standard output: underdotnet runthat is your terminal, and underaspire runthe CLI relays it, live, into its own log under~/.aspire/logs/rather than printing it. A capped copy — its first and last lines, with anything in between marked as elided — also lands in the same resource log once the dashboard exists, so the record ends up where the service is even on a run the console alone would have hidden it from.A non-zero exit fails the service, naming it, the resolved command, the exit code and the tail of the output. During composition that fails the AppHost, exactly as a bad
repositoryor a missingprojectdoes; on a deferred first run it costs that one service and nothing else.Ctrl-C stops it. On a deferred first run the shutdown signal reaches the command's own process tree, so interrupting a long import ends the import rather than leaving it — and its children — running with no AppHost left to belong to. There is no timeout: a legitimate bootstrap can take an hour, and there is no defensible default to cut it off at.
aspire publishdoesn't run it. Publish composes the model, writes the manifest and exits, and a bootstrap produces what a service needs in order to run — so a manifest doesn't depend on it, and paying a multi-gigabyte download on every CI publish to emit one would be nothing but cost. The block is still validated there, so a typo'd mode or a command pointing outside the checkout still fails a publish. The skip is reported, because it has one consequence worth naming: the step runs before the kind judges the checkout, so a service whose committed files aren't enough for its kind on their own — a generated.csproj, a generated project directory — is reported as missing them. Run the AppHost once to materialize the checkout, then publish.
Choosing a mode. All four answer one question — how often does this run — and the guards nest:
| Mode | Re-runs when | For |
|---|---|---|
oncePerCommit (default) |
the command changes, or the checkout moves to another commit | a bootstrap defined by a script the repository commits |
once |
the command changes | an expensive bootstrap independent of the commit |
always |
every start | an incremental script that decides its own work |
never |
— | opting out of a step the catalog declared |
"the checkout moves to another commit" is more concrete than it sounds. A warm managed checkout
is never fetched or moved on your behalf — the commit only moves when you move it — so under the
default oncePerCommit, git pull in a service checkout is what causes prepare to run again on
the next AppHost start. That's an ordinary, frequent action, and nothing about running it reads as
"approve a script to run on my machine"; see SECURITY.md for why that's worth
knowing rather than just worth stating.
The once vs oncePerCommit question is "does the repository define this step?" rather than
"how often" — and the test is where the version is written, not where the expensive part is.
The example above is the default for that reason, even though its java.jarPath looks pinned.
prepare.sh is committed in that repository and hardcodes the GraphHopper version inside itself, so
a team bumping it to 12 moves the commit while the catalog's command stays ["./prepare.sh"]. Under
once the marker would never invalidate, and every developer would keep serving the 11 jar until
someone thought to delete a marker they have never heard of. The catalog's jarPath naming a
version is what makes once look right here; the download is pinned in the script.
Reach for once when the command itself carries everything that decides what it produces, so a
commit cannot change the answer — ["./fetch-model.sh", "--release", "v4.2"], or a step whose whole
input is a URL the catalog spells out. Then a developer committing a one-line README fix shouldn't
pay the download, and under once they don't. The default errs the other way on purpose: an
unexpected re-run is annoying and immediately visible, where a stale artifact is invisible and
surfaces later as a confusing runtime failure.
The command must be safe to re-run. Under always that's self-evident. Under the two guarded
modes it is equally required and less obvious: the completion is recorded only on success, so a
step that fails halfway runs again from the beginning on the next start, against a checkout that
already holds whatever the first attempt managed to produce. A command that can't tolerate that is
incorrect under every mode.
Nothing here detects that a step's outputs are stale — at most that the checkout moved. That is
deliberate: nothing in the catalog says what your script reads and writes, hashing the working tree
is expensive and answers wrongly in both directions, and re-running whenever the tree is dirty would
pay the full bootstrap on every start for exactly the developer "local" exists to serve.
Incremental rebuild is a solved problem with real dependency graphs behind it — make, Gradle,
MSBuild, npm ci — so mode: always with a command that guards itself delegates to them:
[ -f graphhopper-web-11.0.jar ] && [ -d data ] && exit 0
How completion is recorded, and how to force a re-run. A managed checkout keeps a marker at
<checkout>/.git/servicesources-prepare.json, holding a hash of the resolved command and the commit
it ran against. Inside .git deliberately: it's invisible to the service repository's git status,
so it can't be committed by accident, and it dies with the checkout — so a deleted-and-recloned
checkout re-prepares even at the same commit. Delete the file to force a re-run. Each service's
checkout keeps its own, so two services from one repository each pay for their own bootstrap; that
is correct rather than merely tolerable, since a jar downloaded into one clone is not in the other.
Every decision to run says why — no completion recorded, the command changed, the commit moved, the
commit couldn't be determined, or the mode is always. A decision to skip says nothing: that's
the ordinary case, and the marker already records it.
A path checkout declares its own step. A service resolved through local.path never
inherits the catalog's prepare block. Nothing establishes that your directory is even a checkout
of the repository the catalog names — path is validated by "does it exist" and nothing else — so a
catalog command like ["npm", "ci"] would run perfectly happily in a tree that has nothing to do
with it. And it's your working tree, holding your in-flight work, where a repository's own bootstrap
script is entitled to run git clean. So the command that runs there has to be one you wrote:
{
"services": {
"routing": {
"source": "local",
"local": {
"path": "/home/dev/code/routing",
"prepare": { "command": ["./prepare.sh"] }
}
}
}
}
A catalog block on such a service is ignored, not rejected — it's the team's field and applies
correctly to every developer on a managed checkout, so your local override must not turn it into a
failure. Instead a notice at startup names the service and the command that was not run, verbatim,
so you can paste it into the file above. It repeats on every start until you declare a block of your
own, and any declared block silences it — including { "prepare": { "mode": "never" } }, which
is how you say that nothing should run there. (A mode with no command is otherwise rejected on a
path service: you'd have written the half of the block that can't stand alone, and there is no
catalog command for it to apply to.) The marker for such a checkout lives in the tool's own tree, at
<AppHostDirectory>/.servicesources/prepare/<service>.json, keyed on the resolved path as well as
the command — writing into a directory this tool doesn't own is the one thing path promises never
to happen.
This notice is not a general consent rule, and it's easy to read it as one. It exists because a
path checkout is your working tree — the axis it protects is blast radius, not letting a
catalog command mutate a directory this tool doesn't own. A managed checkout under
.servicesources/checkouts/ is exactly the opposite: fully tool-owned, so nothing here asks before
its prepare step (or the dotnet/javascript/java build that follows it) runs the first time —
yet that script is foreign code in the sense that matters for SECURITY.md: written
and reviewed by the service repository, not by you. Meeting this notice on a path service is not a
sign that a managed one asks first too.
Overriding a catalog step per developer. Your block is merged over the catalog's per field,
with one exception: mode overrides on its own, and command/windowsCommand are replaced
together if you supply either. Splitting the pair is never what anyone means — you'd run your own
command on Linux and the team's on Windows.
You write in servicesources.local.json |
Effect |
|---|---|
{"mode": "never"} |
the catalog's step is disabled; nothing runs |
{"mode": "always"} |
the catalog's command runs on every start |
{"command": ["make", "bootstrap"]} |
the catalog's command and windowsCommand are both replaced, so make runs on Windows too. Mode kept |
{"command": [...], "windowsCommand": [...]} |
both replaced, mode kept |
| (absent) | the catalog's block stands |
A block with no catalog block behind it stands on its own: you may introduce a step the catalog never declared.
Two steps can run at once. Eagerly-resolved services prepare one after another, because
AddService() is serial. Deferred ones don't: UseDeferredCheckout() gives each service a task of
its own precisely so that one slow checkout isn't the start of every other, and serializing the step
inside it would put that coupling straight back. So a prepare command has to tolerate running
alongside a different service's command. What those two share is the machine and whatever package
caches they use — never a working tree, since managed checkouts are per-service clones and the one
arrangement that shares a tree (two services on one path) never defers. Most caches are built for
that (~/.nuget/packages, npm's); a Maven local repository is not, so a step that resolves into
~/.m2 and must not overlap with another should take its own lock. A service never prepares
concurrently with itself.
What this deliberately isn't. One command, one marker, per service: no task runner, no ordering
between steps, no caching of produced artifacts across developers, no timeout (Ctrl-C works on a
deferred first run, and a country-sized routing graph has no defensible default) and no injected
environment variables.
prepare also belongs to the "local" service source; the "local" source a
backing service can have means something else
entirely, with no repository and so nothing to bootstrap.
Aspire builds a checkout, on every start
Nothing in this package compiles a checkout, and nothing needs to. A dotnet service is
registered with Aspire's own AddProject, and Aspire launches that resource with dotnet run,
whose working directory is the checkout itself. The build you would otherwise have to arrange is
that command's own implicit incremental build.
So a checkout cloned for the first time compiles when the resource starts — a cold clone with no
bin/ needs nothing done to it first — and a checkout whose ref you change is recompiled on the
next run rather than served from the previous ref's binaries. That last one is worth stating
outright, because the failure it doesn't have would be a quiet one: a service answering with
code you moved away from.
Two things to know when it goes wrong:
The compiler's output isn't in the AppHost's console. It goes to that resource's console in the dashboard, like any other project resource, so the reason a checkout wouldn't compile is one click away rather than in the terminal you launched from. What the AppHost's console does say is that the service isn't running (#150):
fail: Aspire.Hosting.ServiceSources[0] Service 'orders' is configured as 'local' and its resource is not running: it reported 'Finished' with exit code 1. This console does not carry that resource's output, so nothing here says why — its own console in the Aspire dashboard does, at the dashboard URL logged above. A 'local' service runs from a checkout rather than from a project added to this AppHost, and the build of that checkout writes to those same logs — so a failure to compile is reported nowhere else at all.One line per failing resource instance, for every source rather than
"local"alone, whenever it reportsFailedToStartor ends with a non-zero exit code. A replicated service gets one line per replica that failed, naming which one and its own exit code; an unreplicated one gets a single line and no instance id.It errs towards saying nothing rather than crying wolf, because a channel that sometimes lies is one you learn to ignore — which is the problem it exists to fix. So none of these are reported: a terminal state whose exit code was never reported, an orderly Ctrl-C, a resource you stopped yourself from the dashboard, and
RuntimeUnhealthy— the last says your container runtime is unreachable, not that this service failed, and an AppHost started before Docker has finished booting reports it for every container-backed service and then starts them all normally once the runtime answers. A service you restart and that fails again is reported again. The line says only that the service isn't running and where to look: what went wrong belongs to the process Aspire launched, whose output this package doesn't own. On a run with no dashboard — aDistributedApplicationTestingBuilderhost, or an AppHost that turned it off — the line points at the resource's own logs instead of naming a dashboard that isn't there."local"is where this matters most, and why it was asked for there. You never added the project — you wrote a name inservicesources.local.json— and you didn't choose where its code lives, so a resource that quietly fails to appear is one you may not know to look for. The same reasoning already covers a clone that fails for a service nothing waits on; this is the step after it.Two
pathservices in one repository can collide. If both point into the same repository and their projects share aProjectReference, Aspire starts both at once, and two builds write that shared project'sbin//obj/simultaneously — which fails intermittently, with anMSB4018orCS2012naming a file "being used by another process" (microsoft/aspire#15190). Managed checkouts can't hit this: each service gets its own clone under.servicesources/checkouts/<serviceName>/, so there is no shared output directory even when two services come from one repository.
Launching the AppHost from an IDE is the one case this doesn't cover. An IDE that starts project resources itself, to attach a debugger, builds them the way it builds anything else — and a project reached by a path isn't in your solution, so it may not be built at all (microsoft/aspire#2154, open upstream).
First run: UseDeferredCheckout()
On a cold clone, AddService() blocks until the checkout it needs is on disk. Composition
hasn't finished, so the AppHost hasn't started, so there is no dashboard to look at while
several repositories clone — and a checkout that fails throws out of composition and takes the
whole AppHost down with it, including the services that were fine.
builder.UseDeferredCheckout() moves that wait past startup for the case where it hurts: a
"local" service whose managed checkout doesn't exist yet. The resource is registered against
the path its checkout will have, held back with Aspire's own explicit-start behaviour, cloned
while the AppHost runs, and started when its checkout lands:
var builder = DistributedApplication.CreateBuilder(args);
builder.UseDeferredCheckout();
var orders = builder.AddService("orders").WithHttpEndpoint();
The dashboard comes up immediately, checkout progress and failure become resource state you can
see, and one bad clone costs one service instead of the run. The clones stay parallel: a deferred
service's clone starts at its own AddService() call and blocks nobody, so several of them still
run at once — the wall-clock is the slowest clone, not the sum. The one thing that still clones in
turn is a third-party kind handler that declares deferral support and then declines it for a
particular service; the built-in dotnet, java and javascript kinds never do. If you maintain
a kind of your own, Implementing a kind covers the two members that opt into
this and what declining late costs.
It also stops the AppHost downloading repositories it doesn't use. Without deferral the clones have
to start before the AppHost has said which services it wants, so every "local" entry with no
checkout yet is cloned; a deferred one is cloned only when it is added (#76).
The wait is one you can watch. git's own progress becomes the service's state — the phase it is
in, that phase's percentage, and the bytes transferred while a pack is arriving
(Receiving objects 48% · 18.54 MiB) — with every line git writes going to the service's console
logs as it arrives. A failure lands in the same two places, plus one line in the AppHost's own
console saying that service isn't running — the same line
a checkout that won't compile gets, because both are
read off the resource's state rather than off any one failure path. Nothing appears for a
repository small enough that git reports nothing, which is normal rather than a sign of a stall.
What a cold checkout costs, and what it doesn't. This part is about the dotnet kind. The
java and javascript kinds have no launch profile and read nothing out of the repository while
composing, so deferral costs them nothing at all — skip to Scoped deliberately narrowly below.
(One javascript exception, covered there: appType: node and appType: bun are deferred only
when the catalog guarantees a package.json.)
Aspire reads a project's launch profile while composing the AppHost and turns it into endpoints, environment variables and command-line arguments there and then. A deferred service has no repository on disk at that point, so all three come out empty — and nothing re-runs the step.
Environment is put back for you. Once the clone lands, the profile's environmentVariables are
applied to the resource before it starts, and only where the AppHost hasn't already set the same
key, so WithEnvironment and WithReference still win. That matters more than it sounds:
Host.CreateDefaultBuilder takes the environment name from DOTNET_ENVIRONMENT, which most
repositories set in the launch profile and nowhere else, so without this a deferred service runs
as Production while every warm run of it runs as Development.
Values are expanded, and the service's own DOTNET_LAUNCH_PROFILE is set to the profile it was
started under — both as Aspire does them on a warm run. The profile read is whichever one Aspire
itself will select, which is the same selection it makes for the service's command-line arguments
once the checkout has landed: the profile your AppHost was launched under, when the service has
one by that name, and otherwise the first launchable profile in the file. So the process never
ends up with one profile's environment and another's arguments.
Endpoints can't be, because ports are allocated during composition and the spec is frozen. So a deferred service carries only the endpoints you declare:
var orders = builder.AddService("orders").WithHttpEndpoint();
You are not asked for that line up front, and a service that doesn't need it isn't refused — a
run-to-completion worker has no applicationUrl on either path, so demanding one would mean
declaring an endpoint it never listens on. Instead the real launch profile is read once the
checkout lands and the shortfall is reported then, quoting the applicationUrl it actually
found: the project still binds that URL itself and runs, but Aspire allocated no endpoint, so
the port isn't moved off a collision, nothing proxies it, service discovery can't resolve the
service and the dashboard won't link it. Add the line and the next run is whole; it is correct
on a warm checkout too, where it updates the endpoint the profile already created rather than
adding one.
Scoped deliberately narrowly, so the blast radius is first-run-only:
- Only a checkout that doesn't exist yet. A warm checkout — every run after the first — takes the existing eager path unchanged, with full launch-profile fidelity.
- Only managed checkouts. A
pathoverride is your own directory; there is nothing to clone. - Only the
"local"source, and within it only the kinds that own a managed checkout:dotnet,javaandjavascript. The other sources —url,kubernetesandcontainer— never clone a repository, so they have nothing to defer. - Only run mode.
aspire publishand manifest generation clone first as they always have; a manifest written from a repository that isn't on disk would describe a project without its endpoints or its profile environment.
The java and javascript kinds get the same treatment for free, and without the endpoint
caveat above: java requires port in its kind block, and a javascript service always gets an
http endpoint with a port Aspire allocates when the block doesn't name one. Both come from the
committed catalog, so a deferred java or javascript service is identical to a warm one. The
checks that do need the working tree — workingDirectory and the mvnw/gradlew wrapper for
java, appDirectory/package.json/scriptPath for javascript — simply move to just after
the clone, which is where the docs already said they happened. For javascript, the separate
resource that runs npm install is held back with the app and started ahead of it.
appType: node and appType: bun are the one exception, and they opt out rather than guess.
Aspire's AddNodeApp/AddBunApp attach a package manager — and with it the npm install
resource the app waits on — only if they can see a package.json in the app directory, so what a
warm run builds depends on what the repository holds, and a checkout that hasn't landed can't be
looked at. They are deferred only where the answer is already known: runScript is set (which
requires a package.json anyway), or packageManager names one. Otherwise that one service
resolves eagerly, exactly as it does without UseDeferredCheckout(). Every other appType runs a
package.json script by definition and is deferred unconditionally.
Off by default: a service that used to be running by the time Build() returned is started
after it instead, which is visible to anything in your AppHost that assumed otherwise. Call it
before your first AddService(), which is where the decision is made.
Managed checkouts don't inherit your AppHost repository's build settings
A managed checkout is cloned inside your AppHost's repository, and MSBuild, NuGet, the .NET SDK
host and the compiler's analyzer configuration all find their settings by walking up from each
project or source file. Left alone, that means another team's repository gets built under rules
written for yours — most visibly as NU1008 on every pinned PackageReference when your
repository turns on central package management, and least visibly as your packageSourceMapping
confining that repository's restores to your feeds (a leak that hides behind a warm
~/.nuget/packages and only surfaces on a clean machine or in CI).
So alongside the .gitignore, .servicesources/ gets six tool-managed files, plus an empty
.mvn directory, that end those walks there:
| File | Content | Stops |
|---|---|---|
Directory.Build.props, Directory.Build.targets |
<Project /> |
your repository's build customisation |
Directory.Packages.props |
ManagePackageVersionsCentrally=false |
your repository's central package management |
nuget.config |
<packageSourceMapping><clear /></packageSourceMapping> |
your repository's package source mapping (see the note below) |
.editorconfig |
root = true |
your repository's code style and analyzer severities |
global.json |
{} |
your repository's SDK pin and msbuild-sdks versions |
.mvn/ |
empty directory | your repository's .mvn/maven.config, jvm.config and extensions.xml, and Maven's own root-directory detection, for a jar-run Java service (one without its own mvnw, which already stops the walk at the checkout) |
Each of the six files is written with a comment saying what it is and why it's there, since you'll
find them on disk with no git history to explain them, and all six are rewritten whenever their
content is out of date, so upgrading the package updates them. .mvn/ is the exception: Maven
only asks whether the directory exists, so there is no content to write a comment into or to keep
up to date — the directory is created once and then left alone, including anything Maven or a
developer later adds inside it (a maven-wrapper.properties for a service that grows its own
mvnw, for instance).
Each barrier drops a constraint the checkout never opted into, and only supplies what a checkout
lacks. A checkout carrying its own Directory.Build.props, Directory.Packages.props,
.editorconfig, global.json or .mvn/ is found first and keeps its own settings, including
central package management if that's how that repository builds.
Four of these are worth a note:
.editorconfigneeds its own barrier rather than riding on theDirectory.Build.propsone, because analyzer severity written asdotnet_diagnostic.<id>.severity = errorcomes from the.editorconfigitself — not fromEnforceCodeStyleInBuildorTreatWarningsAsErrors. Without it, your repository's code style raises the checkout's own analyzers to errors.global.jsonhas two halves that resolve from different anchors, so the barrier covers one of them completely and the other conditionally.msbuild-sdksresolves by walking up from the project, so it is stopped outright.sdk.versionresolves by walking up from the current working directory, so it is stopped only for a build or run launched from inside the checkout — which is the working directory Aspire gives a project resource. A build you launch with the AppHost directory as its working directory still sees your repository's SDK pin.nuget.configis the one barrier that isn't purely permissive, and the one that doesn't stop the walk. NuGet merges every config from the drive root down rather than stopping at the nearest, so this file can only override the section it names. It namespackageSourceMapping, and NuGet's<clear />discards every mapping accumulated before it — your user-level~/.nuget/NuGet.Configand machine-level ones included, not just your repository's. Inside a checkout, package source mapping is therefore off unless the checkout brings its own, while every inherited source stays reachable; a package that reaches your global packages folder that way is then served from it to restores that do have mapping in force, including your AppHost's own, because that folder isn't itself subject to mapping.The default is this way round because a mapping the checkout was never written against fails its restore outright, naming a source rather than the inherited rule behind it. If you'd rather keep the mapping enforced inside checkouts and deal with those failures, set
SERVICESOURCES_KEEP_PACKAGE_SOURCE_MAPPING=1: the file isn't written, an existing one is removed, and the other five barriers are unaffected.The rest of your
nuget.configstill reaches checkouts for the same merging reason — yourpackageSources,disabledPackageSources,packageSourceCredentialsandconfigsections among them. A repository that clearspackageSourcesand adds only its own feed — the most common customisation there is — therefore still restricts what a checkout can restore, and like the mapping leak it hides behind a warm~/.nuget/packages. ClearingpackageSourcesfrom here isn't the answer: a checkout that legitimately needs your private feed would stop building.
Two upward searches are deliberately left alone, because neither has a neutral value that isn't
also a decision: Directory.Build.rsp (MSBuild takes the first one found walking up from the
project, so your repository's response-file arguments still apply) and .config/dotnet-tools.json
(which affects dotnet tool run inside a checkout). Open an issue if either bites you.
Some JavaScript and Gradle leaks can't be barriered
The barrier pattern above only works for a tool that stops at the nearest file it finds while
walking up. Several of the JavaScript and Gradle mechanisms a javascript or java service
depends on don't work that way, and no file placed in .servicesources/ fixes them:
| Mechanism | Why a barrier doesn't work |
|---|---|
npm's .npmrc |
Not actually a leak: npm's local config comes from the closest ancestor holding package.json or node_modules, which is the checkout itself, so your repository's .npmrc never reaches it. |
pnpm's pnpm-workspace.yaml |
The nearest ancestor wins, so it looks barrierable — but a packages: [] file at .servicesources/ doesn't terminate the walk the way an empty Directory.Build.props does. It makes .servicesources a workspace root with zero matching projects, and pnpm install inside the checkout then reports Scope: all 0 workspace projects, exits 0, and installs nothing — a silent no-op that is worse than the leak it would replace. Neither a checkout-level .npmrc (ignore-workspace=true) nor the equivalent environment variable changes this; only the --ignore-workspace CLI flag does, and this tool does not control how the install command is invoked. |
Yarn Berry's .yarnrc.yml |
Yarn merges rcfiles from the cwd and every ancestor rather than stopping at the nearest one, and there is no root: true equivalent to end the merge. Neutralizing it would mean enumerating every setting (yarnPath, npmRegistryServer, nodeLinker, npmScopes, …) and re-stating a default for each. |
Node's node_modules resolution |
Node consults every ancestor's node_modules in turn; an empty directory does not stop the search the way an empty file stops MSBuild. A dependency your repository happens to have installed above the checkout can resolve into a service that never declared it — passing on your machine and failing in the service's own CI. |
| Gradle's settings-file search | Searched in the cwd and every ancestor up to the filesystem root, stopping at the first hit — so a single-project repository carrying gradlew and build.gradle but no settings.gradle is captured by your repository's. A barrier gains nothing here: Gradle reports "not part of the build defined by settings file … must have its own settings file" regardless of what a barrier file said, so the failure is loud and already names the fix. |
Gradle is the one loud failure here, naming its own fix. The other three are not: Yarn silently
applies whatever your repository's .yarnrc.yml says (a registry, a linker mode, a scope) with no
error at all; node_modules silently resolves a dependency the checkout never declared, the same
"works on your machine, fails in CI" shape as the NuGet gap above; and pnpm's is the quietest of
all — a full install that reports success while installing nothing. Open an issue if one of them
costs you real time.
Several services from one repository
A catalog entry maps one service to one thing to run, so a repository holding several services
gets one entry per service — each naming the same repository, and each selecting its own part
of the tree (project for the default dotnet kind, or the kind's own options block, such as
appDirectory, for the kinds below):
services:
orders:
repository: https://github.com/example/monorepo
project: src/Orders.Api/Orders.Api.csproj
defaultRef: main
payments:
repository: https://github.com/example/monorepo
project: src/Payments.Api/Payments.Api.csproj
defaultRef: main
The catalog is the same either way; what differs is how many checkouts of that repository end
up on your machine, which each developer chooses in servicesources.local.json:
One managed checkout per service — omit
path. Managed checkouts are keyed by service name, soordersandpaymentseach get their own independent clone of the repository, at.servicesources/checkouts/orders/and.servicesources/checkouts/payments/. Each can sit on its ownrefand neither can disturb the other, but the repository is cloned once per service, and an edit to shared code in one checkout is invisible to the other.One checkout shared by every service — set
path. Clone the repository yourself, then point each service at that same directory; the entry'sproject(orappDirectory) is resolved relative to it:{ "services": { "orders": { "source": "local", "local": { "path": "/home/dev/code/monorepo" } }, "payments": { "source": "local", "local": { "path": "/home/dev/code/monorepo" } } } }This is usually what you want when the services share code: one clone, one branch, and an edit to a shared project is picked up by every service at once. The trade-off is that the clone is yours to manage — nothing is ever cloned, fetched or checked out on your behalf — and
local.refcannot be combined withlocal.path.
Mixing the two is fine: services you're actively editing can share one path checkout while
the rest stay on managed clones.
Non-.NET local services: kind
A "local" service is resolved as a .NET project by default. Set kind in the catalog to run
the checkout some other way — the git clone/checkout is identical, only what gets built out of
the resulting directory changes:
services:
frontend:
repository: https://github.com/example/frontend
kind: javascript # optional; defaults to "dotnet"
javascript: # per-kind options block, named after the kind
appDirectory: .
runScript: dev
kind: dotnet (the default) uses the entry's project property and needs no options block.
project is required for that kind, and is a path relative to the service's checkout that must
stay inside it — the rule java.jarPath and java.workingDirectory follow below, and for the same
reason: the catalog is shared configuration you clone rather than write, so it does not get to name
a project elsewhere on a developer's machine. An absolute path (/srv/Api.csproj,
C:\repos\Api.csproj) or one that climbs out (../../shared/Api.csproj) is refused at that
service's AddService() call, before its checkout is used. A project in another repository gets an
entry of its own, naming that repository. The javascript keys are confined to the checkout too,
by a check of their own that judges the resolved path rather than the written one.
Any other kind is resolved by a registered handler, and its options live in a block named after
the kind. Kind names are matched case-sensitively, and a kind with no registered handler fails
at that service's AddService() call, before its checkout is used.
JavaScript: kind: javascript
Runs the checkout through
Aspire.Hosting.JavaScript, which
your AppHost references itself (13.5.2 or newer — see Installation). Reference
it, then call UseJavaScript() once, before the first AddService() call:
using Aspire.Hosting.ServiceSources;
var builder = DistributedApplication.CreateBuilder(args);
builder.UseJavaScript();
var frontend = builder.AddService("frontend");
services:
frontend:
repository: https://github.com/example/frontend
kind: javascript
javascript:
appType: vite # javascript (default) | vite | nextjs | node | bun
appDirectory: web # directory holding package.json, relative to the repo root
runScript: dev # package.json script to run
packageManager: pnpm # npm | yarn | pnpm | bun
port: 4321 # the port consumers reach the service on
Keep
Aspire.Hosting.JavaScripton the same version asAspire.Hosting. Aspire releases the two together and tests them that way. They were also coupled across a friend-assembly boundary until 13.5.0:Aspire.Hosting.JavaScript13.4.6 againstAspire.Hosting13.5.x restores and compiles clean, then throwsMethodAccessExceptionthe first time akind: javascriptservice resolves. This package floors both at 13.5.2, so you get a matched pair by default. If you raiseAspire.Hostingpast that on its own, add a reference at whatever version your AppHost resolves for it — the version below is an example, not a version to copy:<PackageReference Include="Aspire.Hosting.JavaScript" Version="13.5.3" />
Every option is optional:
appType— which integration runs the app:javascript(the default,AddJavaScriptApp),vite,nextjs,node, orbun.nodeandbunexecute a file directly rather than apackage.jsonscript, so they requirescriptPath; the other three run a script and reject it.appDirectory— the directory holding the app'spackage.json, relative to the repository root, which is also the default. It must stay inside the checkout, and — for every app type that runs apackage.jsonscript — it is checked to actually hold one, so pointing it at the wrong directory of a monorepo is reported against the service rather than surfacing later as an npmcould not read package.json.runScript— thepackage.jsonscript to run; the integrations default this todev. Fornode/bunit overrides thescriptPaththey would otherwise execute directly, which needs apackage.jsoninappDirectory— without one those two app types runscriptPathand nothing else, so arunScriptset there is rejected rather than silently ignored.scriptPath— the entry-point file (e.g.server.js) relative toappDirectory. Required byappType: nodeandappType: bun, and rejected for the others. LikeappDirectoryit must stay inside the checkout, and it is checked to exist so a typo is reported against the service rather than surfacing later as acannot find modulecrash.packageManager—npm,yarn,pnpm, orbun, used to install dependencies before the app starts (a fresh clone has nonode_modules). Left unset, the integration's own default applies: npm for most app types, Bun forappType: bun.port/targetPort— the port consumers reach the service on, and the port the app itself listens on. Both are allocated by Aspire when unset.portEnv— the environment variable the app reads its listen port from; defaults toPORT. Rejected forvite/nextjs, whose integrations bind the dev server's port themselves.
The service always gets an http endpoint, so the builder AddService() returns can be passed to
a consumer's WithReference(...) like any other — or to GetServiceEndpoint(), which is how a
consumer names that endpoint without knowing which source produced it
(naming a service's endpoint). Node and Bun must be on PATH for the app types
that use them.
Java: kind: java
Runs the checkout through the .NET Aspire Community Toolkit's
Java integration, which your AppHost references
itself as CommunityToolkit.Aspire.Hosting.Java (13.3.0 or newer — see
Installation). Reference it, then call UseJava()
once, before the first AddService() call — AddService() resolves eagerly, so a kind: java
service registered after it has already run has nowhere to look up its handler:
using Aspire.Hosting.ServiceSources;
var builder = DistributedApplication.CreateBuilder(args);
builder.UseJava();
var catalog = builder.AddService("catalog");
servicesources.yaml:
services:
catalog:
repository: https://github.com/example/catalog
kind: java
java:
mavenGoal: spring-boot:run
port: 8080
The checkout is cloned exactly as for any other "local" service (path, ref, and
defaultRef all behave identically), then handed to that integration to run.
java: block options
| Field | Required | Description |
|---|---|---|
mavenGoal |
one of these three | Run via the Maven wrapper, e.g. spring-boot:run. |
gradleTask |
one of these three | Run via the Gradle wrapper, e.g. bootRun. |
jarPath |
one of these three | Run a pre-built jar with java -jar, relative to workingDirectory. May climb out of it — a monorepo's shared build output directory — but must stay inside the checkout. |
port |
yes | The port the app listens on. Becomes the service's HTTP endpoint, so consumers can WithReference(...) or GetServiceEndpoint() it. |
workingDirectory |
no (defaults to the repository root) | Where in the checkout the project lives — the directory holding pom.xml / build.gradle, and by default the mvnw/gradlew wrapper too. Must stay inside the checkout. |
wrapperPath |
no (defaults to the wrapper in workingDirectory) |
Where the mvnw/gradlew wrapper script lives, relative to the repository root — for the monorepo that commits a single wrapper at its root while the service itself sits further down. Name it without an extension (gradlew, not gradlew.bat) and it works for the whole team: on Windows the .cmd/.bat wrapper beside it is the one run. Only meaningful with mavenGoal or gradleTask. |
args |
no | Extra arguments for whichever run mode is configured — passed to the Maven wrapper, the Gradle wrapper, or the jar. |
mavenGoal, gradleTask, and jarPath are mutually exclusive: exactly one must be set. A
monorepo service, running a Gradle task with an extra argument:
services:
catalog:
repository: https://github.com/example/monorepo
kind: java
java:
workingDirectory: services/catalog
gradleTask: bootRun
wrapperPath: gradlew
args: ["--args=--spring.profiles.active=dev"]
port: 8080
A multi-project Gradle repository (like a multi-module Maven one) commits a single wrapper at its
root rather than one per project, which is what wrapperPath: gradlew names here — without it the
wrapper is looked for in services/catalog, beside the project.
mavenGoal and gradleTask run the repository's own mvnw/gradlew wrapper, so a JDK must be
on the developer's machine but Maven/Gradle itself need not be. That wrapper has to be in the
checkout — there is no fallback to a system-wide mvn/gradle — so a checkout without one is
reported as such, rather than left to surface as a failure to start the app. On Windows the wrapper
run is mvnw.cmd/gradlew.bat, whether it was found by default or named by wrapperPath: the
extensionless scripts beside them are POSIX shell scripts that Windows cannot exec.
Every problem with the block — unknown properties, a missing or out-of-range port, no run
mode or more than one, a workingDirectory, wrapperPath or jarPath escaping the repository,
a wrapperPath set alongside jarPath, a workingDirectory that isn't in the checkout, a
wrapper script that isn't there — is reported by the AddService("catalog") call itself, before
the service has added anything to the app model. The last two are read against the checkout, so
under UseDeferredCheckout(), where there isn't one yet, they
are reported after the clone lands as this service's resource state instead — the same two checks
saying the same two things.
Reaching the rest of the Java integration. The java: block covers how to start the app; it
deliberately doesn't mirror every modifier the Community Toolkit offers. Anything else is reachable
from the AppHost with As<JavaAppExecutableResource>(), which hands back the real resource builder:
builder.AddService("catalog")
.As<JavaAppExecutableResource>()
.WithMavenBuild() // compile before starting
.WithJvmArgs(["-Xmx512m"])
.WithOtelAgent("/path/to/opentelemetry-javaagent.jar");
Use Configure<T>(...) instead for anything that should survive a developer switching that service
to a non-local source — As<T>() throws if the service no longer resolves to a Java resource,
which is the point when the AppHost genuinely requires one.
UseJava() is exported to Aspire's Type System, so a TypeScript AppHost can call useJava()
before addService(...) the same way.
Implementing a kind
A kind implements ILocalResourceKind and registers it from an extension
method:
public sealed class JavaScriptKind : ILocalResourceKind
{
private sealed class Options
{
public string? AppDirectory { get; set; }
public string? RunScript { get; set; }
}
// Optional, and worth implementing whenever Resolve parses rawConfig or reads the checkout:
// this runs immediately before Resolve, against the same repoRoot, and before this service has
// added anything to the app model — so a typo'd options block, or one naming a directory the
// repository doesn't have, is reported without a half-created resource behind it. Not the only
// place to put these checks if your kind supports deferred checkouts — see below.
public void Validate(string serviceName, string repoRoot, object? rawConfig)
{
var options = LocalKindConfig.Parse<Options>(rawConfig, serviceName);
if (options?.AppDirectory is { } appDirectory
&& !Directory.Exists(Path.Combine(repoRoot, appDirectory)))
{
throw new ServiceSourcesConfigurationException(
$"Service '{serviceName}': appDirectory '{appDirectory}' is not in the checkout.");
}
}
public IResourceBuilder<IResourceWithServiceDiscovery> Resolve(
IDistributedApplicationBuilder builder, string serviceName, string repoRoot, object? rawConfig)
{
// repoRoot is the already-cloned, already-checked-out directory.
var options = LocalKindConfig.Parse<Options>(rawConfig, serviceName);
...
}
}
public static IDistributedApplicationBuilder UseJavaScript(this IDistributedApplicationBuilder builder) =>
builder.AddLocalKind("javascript", new JavaScriptKind());
LocalKindConfig.Parse<T> turns the opaque options block into a typed object, and rejects an
unknown property or a block that isn't a mapping with a ServiceSourcesConfigurationException
naming the service. AddLocalKind must be called before the AddService() call for a service of
that kind — resolution is eager, so registering later is too late — accepts each kind name at most
once, and cannot re-register "dotnet" or use a
name that collides with a well-known service property (repository, project, defaultRef,
kind, kubernetes, url, container) — a block by one of those names would be read as that
property rather than as the kind's options.
It also refuses a handler that declares a public Validate taking a service name first and an
options block somewhere, which doesn't match the interface member — the pre-repoRoot
Validate(string, object?), the parameter added in the wrong position, the wrong return type —
naming the kind and the method it found. Validate is a defaulted interface member, so any of
those compile clean and simply stop implementing it, and everything they rejected would be silently
accepted instead. Registration is the only place left to say so; the build won't. A Validate of
your own is left alone unless it looks like that attempt: a private helper, one taking your own
options type, and one like Validate(string message) that carries no options block at all all
register exactly as they did before.
Supporting UseDeferredCheckout(). Two more members, both
optional and both defaulting to "no", decide whether a service of your kind can start before its
checkout lands. Leave them alone and your kind keeps working exactly as it does now, always on the
eager path:
// Answered before anything is registered, so core can decide which services to clone ahead of
// demand. Must touch no filesystem, add nothing to the app model, and never throw - it is called
// for services that may never be added. Answer from the options block alone.
public bool SupportsDeferredCheckout(object? rawConfig) => true;
// Resolve for a checkout that hasn't happened yet: repoRoot is the directory the clone *will*
// land in, and nothing is there yet.
public DeferredLocalResource? ResolveDeferred(
IDistributedApplicationBuilder builder, string serviceName, string repoRoot, object? rawConfig)
{
// The same resource Resolve would build, but from the options block alone.
var options = LocalKindConfig.Parse<Options>(rawConfig, serviceName);
IResourceBuilder<IResourceWithServiceDiscovery> app = ...;
return new DeferredLocalResource
{
Service = app,
// Your checks that need the working tree. Core runs this after the clone and reports a
// failure as that service's resource state.
ValidateCheckout = () => ValidateWrapperScript(repoRoot),
};
}
Build the resource exactly as Resolve would, but read no file under repoRoot — hand those checks
back as ValidateCheckout. Endpoints are the one thing that can't be added later, so a kind that
can only learn its endpoints by reading the repository should return null. Holding the resource
back and starting it once the checkout lands is core's job, and it covers every resource the call
adds to the app model, not just the one returned as Service.
Validate your options block here too.
Validateis paired withResolve, and core calls neither for a service it defers — there is no checkout forValidateto judge the service against, soResolveDeferredruns in their place. A kind that can answertruefromSupportsDeferredCheckoutand rejects a bad block only inValidatehas arranged for that block never to be checked at all underUseDeferredCheckout(). Parse it here as well and throwServiceSourcesConfigurationException. Nothing warns you: implementing bothValidateandResolveDeferredis the ordinary, correct arrangement — the built-injavakind does — so there is no signal to refuse the way a mismatchedValidatesignature is refused.
Returning null from ResolveDeferred after SupportsDeferredCheckout said true is honoured —
legitimate for a kind that can only tell once it has looked at everything — but it isn't free. The
checkout prefetch acts on SupportsDeferredCheckout, so a service that answered true is left out
of the clones started ahead of demand, and declining here drops it onto the eager path with no clone
already running: it is cloned inline, alone, on the AddService() thread rather than alongside the
others. Decide in SupportsDeferredCheckout wherever you can, where the answer is free. A block too
malformed to answer for is false, which routes it to the eager path where Validate reports it
properly.
Private repositories
Clone and fetch for a managed checkout (no path override) authenticate the same way, in order:
- Whatever your
gitalready does. Clone and fetch run thegiton yourPATH, so everycredential.helperyou have configured — Git Credential Manager,osxkeychain,libsecret, a cached PAT, a.netrc-backed helper — is consulted exactly as it is for agit cloneyou type yourself. For an SSH remote that means your SSH agent and~/.ssh/config. Nothing to configure here: ifgit clone <repository>works in the environment the AppHost runs in, so does this. SERVICESOURCES_GIT_USERNAME/SERVICESOURCES_GIT_TOKEN/SERVICESOURCES_GIT_HOSTenvironment variables, if the helpers above yield nothing (e.g. no helper configured) — or if what they yielded was refused, see below.SERVICESOURCES_GIT_HOSTis required — the host (and port, if the URL has one, e.g.git.internal.example:8443), matched case-insensitively, that the token is for. Without it the token is offered to no host at all, since a catalog can list services from more than one host and this token belongs to only one of them.SERVICESOURCES_GIT_TOKENalone (alongsideSERVICESOURCES_GIT_HOST) is enough for hosts that accept any username alongside a personal access token (GitHub, GitLab, Azure DevOps); setSERVICESOURCES_GIT_USERNAMEtoo if your host requires a specific one. Supplied to git as a credential helper of last resort, so it never overrides a helper you configured yourself, and the token is read from the environment rather than passed on a command line where other users on the machine could read it.
The order is a ladder, not a one-shot choice. git stops at the first helper that answers, so if
the host refuses that credential the clone would normally fail there — with the environment token
never offered. It is therefore re-run once with the configured helpers cleared, giving
SERVICESOURCES_GIT_TOKEN its turn. Only after that does the failure stand.
A credential the host actually refuses is reported back to your helper with git credential reject — by git itself, as part of failing — so Git Credential Manager, osxkeychain,
libsecret and friends erase their stored copy and resolve afresh next time instead of serving the
same dead token on every run. Rotating a token therefore takes effect on the next resolution;
nothing is cached inside the AppHost process for a restart to clear.
Nothing ever prompts. GIT_TERMINAL_PROMPT=0 is set on every invocation, and SSH runs with
BatchMode=yes unless you've set your own GIT_SSH_COMMAND, so a repository whose credentials
don't resolve fails immediately instead of hanging builder.AddService() on a prompt nobody is
there to answer.
Credentials are never read from servicesources.yaml (committed) or servicesources.local.json
— there's no field for them in either file, by design, so a secret can't accidentally end up in
the committed catalog. The one way to get one in there anyway is to embed it in the repository
URL itself (https://user:token@host/org/repo); git accepts that form, but it commits the token
along with the catalog, so use one of the two mechanisms above instead. Should such a URL be
configured regardless, every message this tool prints strips the userinfo from it first, so the
token doesn't spread from the catalog into your console and logs.
A clone or fetch that fails for what looks like an authentication reason raises an error naming
the service, the repository, and authentication as the likely cause, rather than a generic
"failed to clone" message. This includes a "not found" response: GitHub, GitLab and Azure DevOps
all answer an unauthenticated request for a private repository with 404 rather than 401, so as not
to leak whether it exists, so the error covers both readings — bad credentials, or a repository
the credentials in use can't see. A rate-limited response is deliberately left out, even though
hosts answer it with the same 403 as a token that's missing a scope: there the credential is
fine and the fix is to wait, so it's reported as the transport failure it is.
When the ladder resolves nothing — no helper yields a credential, and the environment rung has
nothing to offer either because SERVICESOURCES_GIT_TOKEN is unset or because
SERVICESOURCES_GIT_HOST doesn't name this host — the error says so specifically instead of
blaming authentication, because nothing was ever offered for the host to refuse. Watch for this
when the helper works in your shell but not under the AppHost: helpers run in whatever
environment the AppHost process inherits, which is not necessarily your interactive one.
SSH works. A repository written as git@host:org/repo, host:org/repo or ssh://... is
handed to git as written and resolved by your SSH agent and ~/.ssh/config, the same as any
other clone. Because nothing may block on a prompt, SSH runs with BatchMode=yes: a key whose
passphrase isn't already held by an agent, and a host that isn't in known_hosts yet, fail
immediately rather than waiting. Connect to the host once by hand to settle either, or set your own
GIT_SSH_COMMAND, which is left untouched if you do.
"kubernetes" source
Point a service at an already-running instance in a Kubernetes dev cluster via
kubectl port-forward, instead of running it locally at all.
servicesources.yaml:
services:
orders:
kubernetes:
service: orders-svc
port: 8080
servicesources.local.json:
{
"services": {
"orders": {
"source": "kubernetes",
"kubernetes": { "context": "dev-west", "namespace": "orders", "port": 8080 }
}
}
}
Requires kubectl on PATH, authenticated against the named context.
Add scheme: https if the pod behind that port serves TLS:
services:
orders:
kubernetes:
service: orders-svc
port: 8443
scheme: https
kubectl port-forward is a byte-transparent TCP tunnel, so the TLS handshake terminates at the
pod and https://localhost:<port> is the URL that actually works — the scheme is what the service
speaks, not a claim about the tunnel. It defaults to http, and names the endpoint consumers
reference: with scheme: https the service exposes an endpoint named https, so
orders.GetEndpoint("https") resolves. See
naming a service's endpoint.
What the tunnel can't fix is certificate hostname validation — the client connects to localhost
while the certificate names the in-cluster service — so a consumer that validates certificates
needs the usual dev-certificate handling for that.
Set scheme in the developer config to override the catalog for just that developer, alongside a
port override:
{
"services": {
"orders": {
"source": "kubernetes",
"kubernetes": { "context": "dev-west", "port": 8443, "scheme": "https" }
}
}
}
"url" source
Point a service at a fixed, already-known URL — e.g. a Kubernetes ingress, a staging deployment, or any other reachable HTTP(S) endpoint. There's no underlying resource for Aspire to run; the endpoint resolves straight to the configured URL.
Three consequences follow from the service running out of band. The AppHost's
Configure calls are skipped and logged. A container
can't WithReference it — a project or executable can — which fails with a clear error rather than
a DCP stack trace; see #58. And a
consumer's WaitFor on it resolves immediately instead of waiting:
var orders = builder.AddService("orders");
builder.AddProject<Projects.Storefront>("storefront")
.WaitFor(orders); // no-op while 'orders' is "url"
There is no lifetime to order against — the URL is already up, or it isn't, and nothing this
AppHost starts will change that — so the wait is dropped rather than satisfied. WaitForCompletion
goes the same way, since a service running out of band is never going to exit. Every consumer is
covered, containers included: a container that references a url service is still refused as
above, but one that only waits on it starts normally.
The drop is logged, alongside any Configure calls the same service skipped:
warn: Aspire.Hosting.ServiceSources
Service 'orders': skipped WaitFor from 'storefront' because its source is 'url' — it
resolves to a fixed, already-running URL with no local process to configure. ...
The one wait not reported is the WaitForStart Aspire adds itself for each resource an
AddConnectionString expression references — nobody wrote it, so there is no line to point at.
Note what this does not promise: the URL is not fetched, so the consumer starts whether or not
anything is listening. A WaitFor written against a "local" service keeps its full meaning the
moment the service is switched back, which is the point — a developer choosing "url" in their own
servicesources.local.json must not hang an AppHost they don't own. See
#170.
servicesources.yaml:
services:
orders:
url:
url: https://orders.example.com
servicesources.local.json:
{
"services": {
"orders": { "source": "url" }
}
}
Set url in the developer config instead to override the catalog's URL for just that
developer (e.g. pointing at a personal tunnel or local proxy):
{
"services": {
"orders": { "source": "url", "url": { "url": "https://orders.dev.internal" } }
}
}
"container" source
Run a published container image locally via Aspire's own container-runtime integration — image pull and lifecycle are managed entirely by Aspire.
servicesources.yaml:
services:
orders:
container:
image: ghcr.io/company/orders
port: 8080
defaultTag: latest
servicesources.local.json:
{
"services": {
"orders": { "source": "container" }
}
}
Set tag in the developer config to override the catalog's defaultTag for just that
developer:
{
"services": {
"orders": { "source": "container", "container": { "tag": "v1.4.2" } }
}
}
Add scheme: https if the image serves TLS on port. Like port, it's catalog-only — the image
decides what it serves, so there's nothing per-developer to override — and it defaults to http:
services:
orders:
container:
image: ghcr.io/company/orders
port: 8443
scheme: https
Combining sources on one catalog entry
A single servicesources.yaml entry can carry blocks for every source at once — the catalog
just describes how each source would resolve the service; each developer's
servicesources.local.json picks which one actually applies to them:
services:
orders:
repository: https://github.com/example/orders
project: src/Orders.Api/Orders.Api.csproj
kubernetes:
service: orders-svc
port: 8080
url:
url: https://orders.example.com
container:
image: ghcr.io/example/orders
port: 8080
defaultTag: latest
A developer editing the service picks "local"; one debugging against a shared dev cluster
picks "kubernetes"; one who just needs it reachable picks "url" or "container" — same
catalog entry, same AddService("orders") call in the AppHost, no code changes either way.
Each developer's own servicesources.local.json just names which source applies to them —
editing orders locally:
{ "services": { "orders": { "source": "local" } } }
debugging against a shared dev cluster:
{ "services": { "orders": { "source": "kubernetes", "kubernetes": { "context": "dev-west", "namespace": "orders", "port": 8080 } } } }
or just needing it reachable, not caring how:
{ "services": { "orders": { "source": "url" } } }
The source value is matched without regard to case, so "local", "Local" and "LOCAL" all name
the same source. A name none of the four has is refused at composition time, naming the ones that
exist. (The kind names in servicesources.yaml are the exception — those are case-sensitive,
because anything may register one and two registrations must not be able to collide by spelling.)
Overriding servicesources.local.json
The file is read through the AppHost's own IConfiguration, as the lowest-precedence source in
the standard provider chain, under the key ServiceSources:Services:<service>. It is still the
place a developer normally writes a source selection, and a .NET or TypeScript AppHost authors it
identically — but every provider above it can override an entry without the file being touched:
| Layer | Overrides the file? |
|---|---|
servicesources.local.json |
— (the base) |
appsettings.json |
yes |
appsettings.{Environment}.json |
yes |
| User secrets | yes (requires a UserSecretsId in the AppHost csproj; without one the layer is simply absent) |
| Environment variables | yes |
| Command-line arguments | yes |
The
appsettingslayers need the file in the AppHost's output directory. An AppHost project ships noappsettings.json, so unlike a web project it has no item copying that pattern tobin/, and a file placed beside the.csprojis silently never found — there is no error, the layer is simply absent. Add it explicitly:<ItemGroup> <Content Include="appsettings*.json" CopyToOutputDirectory="PreserveNewest" /> </ItemGroup>
The
ServiceSources:*keys reach the AppHost's ownIConfigurationon its first ServiceSources call, not before.servicesources.local.jsonis a file of ours, read from the AppHost directory and re-keyed into the chain by whichever ServiceSources method the AppHost calls first — aUseX()registration, or the firstAddService(). A read placed above all of them sees the chain without that layer, so a selection written only in the file comes backnull, silently, since a missing key is not an error:// null — nothing of ours has been called yet, so the file is not in the chain. var source = builder.Configuration["ServiceSources:Services:orders:source"]; builder.UseJavaScript(); // "local" — the file joined the chain on the line above. source = builder.Configuration["ServiceSources:Services:orders:source"];Reading these keys from an AppHost should be rare. Scoping a declaration to one source is what sends an AppHost looking for them, and
Configure<T>already does that scoping for you.
The immediate payoff is a single run with a different source and no edit to a file you'd have to
remember to change back. source itself isn't nested under a block, so this still works verbatim:
ServiceSources__Services__orders__Source=url dotnet run
Overriding a field works the same way, but gains its source's block segment —
ServiceSources__Services__orders__Local__Ref, ServiceSources__Services__orders__Container__Tag,
and so on. (__ is the .NET configuration separator for :, and is what you want on every
platform.) Setting one of these to a blank value unsets the field, rather than setting it to an
empty string — ServiceSources__Services__orders__Local__Path= leaves the service with no path
at all, even one servicesources.local.json (or a layer in between) configured. It does not fall
back to that lower layer's value: configuration merges the layers before this package sees them,
so the blank is what arrives and the field ends up absent — which for path means the service gets
its managed checkout, exactly as if no layer had ever named one.
Blank means empty, exactly, whatever the field's type. A value of one or more spaces is refused
rather than read as either an unset field or a value of its own:
ServiceSources__Services__orders__Kubernetes__Port= drops the port, and
ServiceSources__Services__orders__Local__Path=" " is an error naming the spelling that works
rather than an override silently discarded — which is what a stray space surviving a CI variable
used to cost, leaving the service on its managed checkout with nothing said.
A service whose name contains a hyphen — order-service, say — makes a variable name a shell won't
accept as an inline assignment, so pass it through env instead:
env 'ServiceSources__Services__order-service__Source=url' dotnet run
The key itself is fine either way; it's only the one-line NAME=value command form that needs this.
The nesting is what makes this override story work at all: switching source from a higher layer
leaves the previous source's block sitting in the file, unread rather than removed. Nothing has to
be deleted from servicesources.local.json to switch a service away from the source it names there
— the fields for every other source can sit in the file unused, ready for the next switch back.
CI is the other case. A build agent has no developer to pick sources for it, and cloning every service to run one test is waste, so pin them from the environment and ship no file at all:
env:
ServiceSources__Services__orders__Source: container
ServiceSources__Services__payments__Source: container
Named profiles fall out of the same mechanism. Put the cluster-facing selection in
appsettings.Cluster.json next to the AppHost:
{
"ServiceSources": {
"Services": {
"orders": {
"source": "kubernetes",
"kubernetes": { "context": "dev-west", "namespace": "orders", "port": 8080 }
}
}
}
}
and choose it per run by passing the environment as an argument to the AppHost:
aspire run -- --environment Cluster # everything after -- goes to the AppHost
dotnet run -- --environment Cluster # or launching the AppHost directly
DOTNET_ENVIRONMENT=Cluster does not work under aspire run. The CLI sets
ASPNETCORE_ENVIRONMENT and DOTNET_ENVIRONMENT to Development itself when it launches the
AppHost, so a value exported in your shell is overwritten and the profile is silently not
selected — you get the base file's selection with no indication that the profile was ignored.
The variable route only works when you run the AppHost yourself with dotnet run. The
command-line form above works in both.
Note the extra ServiceSources root: inside the AppHost's shared configuration the entries are namespaced, while servicesources.local.json keeps its bare
services root because it is a file of ours, read from the AppHost directory and re-keyed as it
joins the chain.
Two failures are reported differently on purpose, because a typo in a configuration key produces an empty section rather than an error:
- Nothing configured anywhere —
ServiceSources:Servicesis empty in every source. The message says so, names theservicesources.local.jsonpath it looked for and whether it was found, and lists every source consulted. - This one service isn't configured — other services resolved, this one has no entry. The
message names
ServiceSources:Services:<service>:sourceand the environment variable that would set it.
Configuring a resolved service
AddService() returns a builder over the real resource Aspire runs, so the AppHost can inject
its own configuration — connection strings, generated secrets, a sibling's endpoint, wait ordering.
Values like these come from the AppHost's own graph and can't be written into
servicesources.yaml/servicesources.local.json.
The resolved resource's type depends on the source, which each developer chooses, so name the capability you need and it is checked at composition time:
var backend = builder.AddService("backend")
.Configure<IResourceWithEnvironment>(r => r
.WithReference(ordersDb)
.WithEnvironment("DBPASSWORD", postgres.Resource.PasswordParameter)
.WithEnvironment("ENCRYPTIONKEY", builder.AddParameter("EncryptionKey", new GenerateParameterDefault(), secret: true))
.WithEnvironment("Services__CommonAuth", commonAuth.GetServiceEndpoint()))
.Configure<IResourceWithWaitSupport>(r => r.WaitForCompletion(migrationService));
As<T>() is the same cast without the callback, and reaches anything Configure would — including
a non-dotnet kind's own extension methods:
backend.As<JavaScriptAppResource>().WithRunScript("dev");
Configure is skipped for the "url" and "kubernetes" sources, and the skip is logged at
startup. Both resolve to something already running elsewhere — a "url" service has no local
process at all, and a "kubernetes" service is a kubectl port-forward in front of a remote one,
so environment variables applied here would configure kubectl rather than the service. Those
services are expected to be configured wherever they actually run.
The one exception is wait ordering on a "kubernetes" service, which still applies:
Configure<IResourceWithWaitSupport> (and WaitForService / WaitForServiceCompletion) reach a
real, registered kubectl port-forward executable, and holding that back until a migration
finishes is exactly what the AppHost asked for. Only configuration that would land on the wrong
process is dropped. A "url" service skips wait ordering too, since it has no registered resource
for Aspire to hold back.
That is this service waiting for something else. The other direction — something else waiting for
this service, consumer.WaitFor(service), which is ordinary Aspire rather than a Configure
call — is dropped for "url" and honoured for every other source, including "kubernetes". That
drop is reported in the same message as the service's skipped Configure calls. See
the "url" source.
Skipping rather than failing is deliberate: a developer switching a service to a remote source in
their own servicesources.local.json must not break a Program.cs they don't own. You'll see:
warn: Aspire.Hosting.ServiceSources
Service 'backend': skipped Configure<IResourceWithEnvironment> because its source is
'kubernetes' — it resolves to a 'kubectl port-forward' in front of an already-running
service, so the configuration would reach kubectl rather than the service. ...
As<T>() throws for those sources instead of skipping — it has to return a builder, and handing
back the kubectl executable would silently configure the wrong process. Prefer Configure for
anything that should survive a source switch. It follows the same wait-ordering exception:
As<IResourceWithWaitSupport>() on a "kubernetes" service returns the port-forward's builder
rather than throwing.
From a guest-language AppHost
Configure<T> is generic, and Aspire's Type System erases a generic method's type parameter to its
constraint — which for Configure<T> erases the capability being requested, since that is all T
says. So guest languages get a set of non-generic equivalents instead, one per shape, each with its
own name (two exports that project to the same generated name collide, and only one survives):
const payments = await builder
.addService('payments')
.withServiceEnvironment('DEMO_INJECTED_BY_APPHOST', 'true')
.withServiceReference(inventory);
| TypeScript | C# equivalent |
|---|---|
withServiceEnvironment(name, value) |
.Configure<IResourceWithEnvironment>(r => r.WithEnvironment(name, value)) |
withServiceEnvironmentFromParameter(name, parameter) |
…WithEnvironment(name, parameter) |
withServiceEnvironmentFromEndpoint(name, endpoint) |
…WithEnvironment(name, endpoint) |
withServiceReference(other) |
…WithReference(other) |
withServiceConnectionString(source) |
…WithReference(source) |
waitForService(dependency) |
.Configure<IResourceWithWaitSupport>(r => r.WaitFor(dependency)) |
waitForServiceCompletion(dependency, { exitCode }) |
…WaitForCompletion(dependency, exitCode) |
withServiceArg(arg) |
.Configure<IResourceWithArgs>(r => r.WithArgs(arg)) |
withServiceHttpsEndpoint() |
.Configure<IResourceWithEndpoints>(r => r.WithHttpsEndpoint()) |
withServiceHttpEndpoint() |
…WithHttpEndpoint() |
They delegate to Configure<T>, so out-of-band sources are skipped and logged exactly as above —
including the wait-ordering exception, which waitForService and waitForServiceCompletion inherit.
In C# they're hidden from IntelliSense — use Configure<T>, which reaches every Aspire extension
method rather than just these.
Backing services: databases, brokers and caches
A service usually depends on a database or a broker, and a developer wants the same choice for it
that they have for the service: run it locally, or connect to the one already running in the shared
dev cluster. AddBackingService() is that choice.
var ordersDb = builder.AddBackingService("orders-db",
local: () => builder.AddPostgres("orders-pg").AddDatabase("orders-db", "orders"));
builder.AddService("orders")
.Configure<IResourceWithEnvironment>(r => r.WithReference(ordersDb))
.Configure<IResourceWithWaitSupport>(r => r.WaitFor(ordersDb));
WaitForstops meaning anything under"direct". TheWaitForabove waits properly under"local", where a real database resource sits behind it, and under"kubernetes", where a health check on the forwarded port holds the consumer back until the tunnel is actually listening. Switch that backing service to"direct"and it is satisfied immediately instead: the resource is a connection string, and Aspire marks it running as soon as that string is available — which is at once, without the instance you pointed at having been checked. Nothing fails and nothing hangs; the consumer simply starts earlier than you asked it to. Tracked as #220.
Two things are different from AddService():
- There is no catalog. A backing service is declared by the
AddBackingService()call itself, so nothing goes inservicesources.yaml— an AppHost that only connects to a database needs no catalog file at all. - The local case is your code, not ours. Provisioning a database locally is already expressed
perfectly well as
builder.AddPostgres(...), by the person who knows what it should be, so the"local"source runs that factory and returns its result unchanged. Re-expressing image and version as catalog fields would be a worse version of Aspire's own integrations.
Because the local case is a factory, this works for anything with a connection string —
AddSqlServer(...).AddDatabase(...), AddRabbitMQ(...), AddRedis(...) — with no support needed
per backend.
Sources
Configured under a new backingServices: section of servicesources.local.json, alongside
services: and read through the same configuration layers, so every override in
Overriding servicesources.local.json applies here too:
{
"services": { "orders": { "source": "local" } },
"backingServices": {
"orders-db": {
"source": "direct",
"direct": { "connectionString": "Host=localhost;Port=5432;Database=orders;Username=dev" }
}
}
}
source |
Meaning |
|---|---|
"local" |
Run the local factory. The default — a backing service with no entry resolves here, so an AppHost nobody has configured runs as it reads. |
"direct" |
Connect to direct.connectionString. Covers a database the developer started by hand and a cluster database published through an ingress: from the AppHost's side both are an address to connect to, with no process to manage. |
"kubernetes" |
Open a kubectl port-forward to a Service in a dev cluster, and connect to the local end of it. See Reaching a backing service in a cluster. |
"direct" is named for the one thing that distinguishes it — nothing in the way — rather than for
where the database runs. It is not "remote", because the common case is a localhost the
developer started themselves, and not "external", which Aspire already uses for an external
HTTP service.
Each source's settings live in a block named for it, exactly as a service entry's do, so a higher
configuration layer can switch source without a field from the source you switched away from
being read alongside it.
Write the address as reached from outside Aspire.
"direct"hands the connection string to consumers as given — nothing about it is rewritten, because there is nothing for this AppHost to manage. That is worth spelling out for the case a developer reaches for while experimenting: pointing"direct"at a container the same AppHost runs. The host and port shown for a container endpoint in the dashboard, and byaspire describe, are Aspire's endpoint proxy — not the container's own published port. The proxy listens only while that AppHost is running, and a fresh run assigns it a new port, so a connection string written against it stops working as soon as either changes. Take the real published port from your container runtime (docker port/podman port) — or keep"local", which is what "a database this AppHost runs" already means.
Reaching a backing service in a cluster
"kubernetes" connects to a database, broker or cache running in a dev cluster, through a
kubectl port-forward this AppHost opens and Aspire manages for the life of the run:
{
"backingServices": {
"orders-db": {
"source": "kubernetes",
"kubernetes": {
"service": "orders-pg-rw", // the Kubernetes Service to forward to
"port": 5432, // the port it listens on inside the cluster; a block
// of named ports forwards several — see below
"context": "dev-west", // the kubectl context to forward through
"namespace": "orders", // optional; "default" when omitted
"connectionString": "Host=localhost;Port=${port};Database=orders;Username=dev;Password=hunter2"
}
}
}
}
Write ${port}, not a number. The local end of the tunnel is allocated when the AppHost starts,
so that two backing services forwarded at once cannot collide — which means it is not a number you
can write down. ${port} is replaced with it before any consumer sees the string. A connection
string that names no ${port} is refused at startup rather than run, because the alternative fails
silently: Port=5432 copied out of a manifest addresses port 5432 on your machine, where your own
database container may well be listening, and the AppHost would connect to the wrong database with
every resource reporting healthy.
Two resources appear in the dashboard: the backing service itself, and the kubectl process
underneath it as orders-db-tunnel. kubectl's own output — a bad context, a Service that does not
exist, an expired credential — lands in that resource's logs.
The health badge is on the backing service, not on the tunnel. For the few seconds before the
forward is up, the tunnel shows as running while the backing service above it shows as unhealthy;
that is the right way round, because the backing service is what consumers wait for. The check is
deliberately not duplicated onto the tunnel: Aspire probes once per resource carrying it, and every
probe is a connection kubectl logs — which would bury the output you go there to read. A kubectl
that exits outright shows up as a failed resource regardless.
- A Service, not a pod. A pod name carries a replica-set suffix that changes on every rollout;
kubectl port-forwardagainst a Service picks a backing pod itself. contextis required, and deliberately not defaulted to whateverkubectlis currently pointed at. Defaulting would make the AppHost's behaviour depend on a shell you may not have opened today, and the failure would be a connection to the wrong cluster rather than an error.namespacedefaults todefault, which is notkubectl's own default —kubectluses the namespace configured on the context. Same reasoning: what the AppHost does should not depend on akubectl config set-context --current --namespace=…nobody recorded.kubectlmust be onPATH. Nothing is bundled, and this source runs the same binary you do.
Several ports through one tunnel
A broker usually wants two: the one the application speaks, and a management port you open in a
browser. Write port as a block that names each one, and reach them as ${port:<name>}:
{
"backingServices": {
"orders-events": {
"source": "kubernetes",
"kubernetes": {
"service": "rabbitmq",
"port": { "amqp": 5672, "management": 15672 },
"context": "dev-west",
"connectionString": "amqp://dev:hunter2@localhost:${port:amqp}/"
}
}
}
}
One kubectl process carries every pair, because kubectl port-forward accepts several against
one Service — two entries would mean two processes and two tunnels to the same Service. Each
forwarded port gets its own health check, all of them on the backing service, so a WaitFor waits
for the whole tunnel and the dashboard says which half is missing while it comes up.
Not every forwarded port has to appear in the connection string: the management port above is forwarded so you can open it, and nothing dials it from the app.
${port} and ${port:<name>} do not mix. A port written as a number forwards one unnamed
port and takes ${port}; a block that names its ports takes ${port:<name>} for each. Writing the
other one is refused at startup, naming the ports this backing service actually forwards — including
a "did you mean" when the name is close to one of them.
Two spellings of one port name that differ only in case —
amqpandAMQP— are the same configuration key. In a singleservicesources.local.jsonthat is a duplicate key, and the JSON parser refuses the whole file; spread across two layers they merge instead, and the casing you see is whichever layer wrote last.
Reading credentials out of a Kubernetes secret is covered under
Connection-string placeholders, with ${secret:<name>:<key>}.
The local factory's resource must be named after the backing service
Aspire's WithReference(...) keys the connection string on the referenced resource's own name,
and under "local" that resource is whatever your factory built. So the names have to agree, and
AddBackingService refuses them when they do not:
// Good — one name everywhere. Switching source changes the value and nothing else.
builder.AddBackingService("orders-db", () => builder.AddPostgres("pg").AddDatabase("orders-db", "orders"));
// → ConnectionStrings__orders-db, under every source
// Refused at startup, naming both names.
builder.AddBackingService("orders-db", () => builder.AddPostgres("pg").AddDatabase("orders"));
// → would be ConnectionStrings__orders under "local", ConnectionStrings__orders-db under "direct"
AddDatabase("orders-db", "orders") names the Aspire resource and the actual database separately,
which is what to reach for when the two want different names. Casing counts. .NET folds it when
reading configuration, so a .NET consumer would not notice — but the environment variable itself
differs, and a JavaScript or Java service reads process.env / System.getenv case-sensitively.
This is a rule rather than advice because the alternative remedy is not available everywhere. In C# a consumer can pin the key from its own side:
builder.AddService("orders")
.Configure<IResourceWithEnvironment>(r => r.WithReference(ordersDb, "OrdersDb"));
// → ConnectionStrings__OrdersDb, under every source
WithReference's second argument overrides the source resource's name for the connection string.
Reach for it when the app already reads a particular name — but note it is C#-only today, because
the generated shim takes the source alone
(#209). That is this package's gap
rather than a limit of guest languages: a project's own withReference already accepts
{ connectionName } from TypeScript. Until the shim offers the same, naming the factory's resource
after the backing service is the one answer every AppHost can give, which is why it is the one
enforced.
If the resource is not yours to rename — a shared helper, or one handed to you — return a connection string of your own that forwards it:
builder.AddBackingService("orders-db", () =>
{
var shared = SharedHelpers.AddOrdersDatabase(builder); // names its resource whatever it likes
return builder.AddConnectionString("orders-db", ReferenceExpression.Create($"{shared}"));
});
The forwarding resource carries the same value under the name the rule wants, so the key stays put
across a source switch. This used to be the case WithReference(db, connectionName) covered; the
rule makes that unreachable for a backing service, since the throw happens first, so the wrap is
what replaces it.
The wait mostly survives the wrap, but loses its health check. What
AddBackingServicehands back is the forwardingConnectionStringResourcerather than the database the factory built — and Aspire does follow the reference: the wrapper itself sits inWaitinguntil the database it forwards is running, so a consumer'sWaitFor(ordersDb)still holds back for Postgres to start.What it stops honouring is the database's health check. Waiting on the database directly waits for it to be healthy; waiting on the wrapper is satisfied once the database is merely running. Measured on a live host with a real Postgres — the consumer waiting on the wrapper started about seven seconds before the one waiting on the database, while a consumer waiting on nothing started three seconds before either.
So rename the resource wherever you can; the wrap is a smaller loss than it looks, but it is not free. Tracked as #220, together with the
"direct"case, where the connection string references nothing and the wait is therefore satisfied at once.
Connection-string placeholders
A connectionString is normally a literal. Braces reserve nothing — Driver={PostgreSQL},
Server={host}\instance and PWD={secret} all pass through exactly as written, doubled braces
included, so ODBC values keep their own doubling rule intact (PWD={pa}}ss} is the password
pa}ss, and stays that).
Placeholders open on ${, which no connection-string dialect uses. Two are recognised and reserved
for the sources that can resolve them; a source that cannot rejects one with a message saying why:
${port}— the local end of the tunnel, under"kubernetes", where it is required unless the whole connection string is one${secret:…}(below), which carries a port already."direct"forwards nothing, so there write the port the backing service already listens on.${port:<name>}— one of several ports forwarded through the one tunnel, whereportis written as a block that names each. See Several ports through one tunnel.${secret:<name>:<key>}— a value read from a Kubernetes secret, under"kubernetes". The fetch is deferred: the placeholder becomes a parameter Aspire resolves when something first asks for the value, so an unreachable cluster costs one failed parameter rather than an AppHost that will not start. The value is marked secret, so the dashboard masks it, and reading the same placeholder twice fetches once."direct"has no cluster to resolve one against and refuses it, naming"kubernetes"as the source that does.A secret's name and its keys are letters, digits,
-,.and_, with the name starting with a letter or a digit — the cluster's own rule, checked here so that nothing else can be smuggled into thekubectlcommand that reads it.A secret holding the whole connection string works too, which is the shape a hand-authored Sealed Secret usually has. Write the template as exactly one placeholder:
{ "backingServices": { "orders-db": { "source": "kubernetes", "kubernetes": { "service": "orders-pg-rw", "port": 5432, "context": "dev-west", "namespace": "orders", "connectionString": "${secret:orders-cs:connectionString}" } } } }Then the port-forward listens on the same port
portnames rather than an allocated one — there is nothing in the template to substitute a local port into — and the in-cluster host the secret was written against is rewritten tolocalhost, in any of the four forms a pod resolves (orders-pg-rw,.orders,.svc,.svc.cluster.local), wherever a connection string can put a host. Because the allocated port is given up, a local port already in use is refused up front — and for the same reason, this mode takes a singleportrather than a block that names several: with only the one number to match against, give it a single port instead. So is a secret whose own port is not the one being forwarded, and one that names the service in no form this can rewrite. Per-field placeholders stay preferred wherever the secret offers them.
A malformed placeholder — ${secret:orders-creds}, with no key — fails when the AppHost starts,
naming the backing service and the configuration key, rather than reaching the app as text.
A ${ begins a placeholder only when the word after it — up to the first : or }, or to the
end — is exactly port or secret, in any casing. Equality, not a prefix, so everything else
is text: ${portal}, ${secretariat}, ${secrets:a} and ${DB_PASS} all pass through untouched —
which is what keeps a connection string working when something else in your toolchain is the one
expanding ${…}.
The remaining cost is that ${port} and ${secret:…} themselves cannot be written as literal text
in any casing: a keyword-shaped token that this package cannot read fails at startup rather than
passing through, and there is no escape. Nothing has wanted one. $ is not otherwise special, so
$${port} is available as an escape if that ever changes — it is a literal $ followed by a
placeholder today.
The syntax was
{port}during development and moved before release (#207). Reserving a shape inside braces leftPWD={secret}— ODBC for a password that happens to be the word — impossible to write. Escaping could not fix it: doubling is the syntax ODBC already uses, and collapsing it silently corrupted working connection strings in both directions.
Setting a template from a shell: quote it with single quotes
${…} is also what a POSIX shell, docker-compose and a GitHub Actions run: block use for their
own variables, so a template set through an environment variable can be expanded away before the
AppHost ever sees it. Double quotes do not help — they protect the ; and not the ${:
# Wrong: double quotes, so the shell substitutes ${port} (unset) and the AppHost gets "Host=db;Port="
env "ServiceSources__BackingServices__orders-db__Direct__ConnectionString=Host=db;Port=${port}" aspire run
# Right: single quotes, so ${port} reaches the AppHost intact
env 'ServiceSources__BackingServices__orders-db__Direct__ConnectionString=Host=db;Port=${port}' aspire run
env rather than export, because a backing service whose name contains a hyphen — orders-db
here — makes an environment variable name that is not a valid shell identifier, and both
export NAME=… and the NAME=… command prefix refuse it outright. env 'NAME=value' command
accepts any name, and so do docker-compose's environment:, launchSettings.json and a workflow's
env: block, none of which put the name through a shell.
That is a separate question from the value. launchSettings.json leaves a value alone entirely;
docker-compose does its own ${…} interpolation, so escape it there as $${port}; and a workflow's
run: block is a shell, so it needs the single quotes above.
Under "direct" nothing reports the mangled case, because what arrives is a valid template that
simply has no placeholder in it — which is also what someone writing a literal port produces, and
that is a perfectly good "direct" connection string. Under "kubernetes" it is reported, since
a ${port} is required there: the error names the shell alongside the spelling, because a template
that lost its placeholder and one that never had it arrive looking the same.
This does not apply to servicesources.local.json, appsettings.json or user secrets, where $ is
an ordinary character — which is where a template normally lives.
Configuration that nothing reads is reported
A backing service with no entry legitimately runs from its local factory, so an entry whose key
matches no AddBackingService() call cannot be told apart at read time from one that was never
written:
"backingServices": {
"orders_db": { "source": "direct", "direct": { "connectionString": "…" } } // note the underscore
}
orders-db would revert to "local" and start the container you were trying to avoid. Once the
AppHost is composed the set of names AddBackingService() was called with is known, so this is
reported as a warning at startup, naming the entry and the declared name it resembles. A misspelled
backingServices root key — which does the same to every backing service at once — is reported the
same way.
Both are warnings rather than errors, because a shared servicesources.local.json may legitimately
carry entries for backing services only some configurations add. There is no way to switch them off;
if you find yourself wanting one, say so on
#206.
From a guest-language AppHost
addBackingService is exported, and the local factory crosses the boundary as an ordinary callback:
const ordersDb = await builder.addBackingService('orders-db',
async () => builder.addPostgres('orders-pg').addDatabase('orders-db', 'orders'));
Naming a service's endpoint
A consumer that wants the service's URL asks for an endpoint. Aspire names endpoints, and
GetEndpoint("https") looks like the obvious spelling — but the endpoint name a resolved service
exposes is decided by whichever source resolved it:
| Source | Endpoint name |
|---|---|
"local", kind: dotnet |
whatever the launch profile's applicationUrl declares (http, https, or both) |
"local", non-dotnet kinds (javascript, java) |
http |
"url" |
the configured URL's scheme |
"kubernetes", "container" |
the configured scheme, http unless set |
So naming a scheme resolves only while the service happens to be on a source that produces it.
Switch that service and the consumer breaks — and it breaks late: composition succeeds, and the
throw comes from Aspire's ExpressionResolver when the consumer's environment is gathered, so it
surfaces as a FailedToStart on the consumer naming a service the consumer never changed:
System.InvalidOperationException: The endpoint `https` is not defined for the resource
`common-auth`. Available endpoints: `http`.
GetServiceEndpoint() is the portable spelling. It asks for the endpoint the service exposes and
survives a source switch:
var commonAuth = builder.AddService("common-auth");
builder.AddProject<Projects.Web>("web")
.WithEnvironment("Services__CommonAuth", commonAuth.GetServiceEndpoint());
It resolves to the endpoint named https if there is one, else http, else the service's only
endpoint whatever it's named — the same order Aspire's own service discovery resolves
"https+http://" in, so a service exposing both hands back the endpoint Aspire would have picked
itself. It throws at composition time, naming the service and its source, if the service exposes no
endpoint at all or exposes several with none named http or https; in that last case there's no
single endpoint to mean, so name the one you want with GetEndpoint("<name>").
The endpoint is chosen when you call it, so call it after any Configure that adds one. The
EndpointReference it returns is lazy in the usual way — the URL resolves once Aspire has allocated
the port.
WithReference(service) plus service discovery is portable too, and is the better fit when the
consumer speaks service discovery: it injects every endpoint the service has under
services__<name>__<scheme>__<index>, and a client resolving https+http://common-auth picks
whichever is there. GetServiceEndpoint() is for the case a plain URL in a plain environment
variable is what the consumer reads.
GetEndpoint("<scheme>") still has its place — a service you know will never move off "local",
or an endpoint you added yourself through Configure<IResourceWithEndpoints>. Just don't reach for
it across a service whose source a developer chooses.
From a guest-language AppHost it's getServiceEndpoint(), and the value flows into Aspire's own
withEnvironment:
await builder
.addExecutable('probe', process.execPath, '.', ['-e', probeScript])
.withEnvironment('INVENTORY_URL', inventory.getServiceEndpoint());
Sample
samples/DemoAppHost is a minimal working AppHost demonstrating all three easily-runnable
sources: orders via a real managed "local" git checkout (a small project cloned from
dotnet/aspire-samples), inventory via the
"url" source (pointing at httpbin.org, a live public test API), and
payments via the "container" source (the nginxdemos/hello hello-world image) — run it to
see the whole flow end to end. ("kubernetes" isn't demoed here since it needs a real cluster
and kubectl; see its section above.)
It also carries a catalog service showing kind: java — a "local" checkout of
Spring PetClinic run with its own Maven
wrapper. builder.UseJava() is wired up, but AddService("catalog") is commented out and the
service is left out of servicesources.local.json.example, since unlike the three above it needs a
JDK. To run it, do both: uncomment the call and add "catalog": { "source": "local" } to your
servicesources.local.json. Leaving it out of that file by default is what keeps the sample from
cloning PetClinic on its first run: the sample does not call UseDeferredCheckout(), so the first
AddService clones every "local" entry there that has no checkout yet, whether or not you add it.
cd samples/DemoAppHost
cp servicesources.local.json.example servicesources.local.json
aspire run
A TypeScript AppHost equivalent — proving AddService() is correctly exported and registers with
Aspire's Type System from a guest language, and that a resolved service can be
configured from TypeScript — lives in
samples/DemoAppHostTypeScript. Both of its services use the "container" source so that
payments can withServiceReference(inventory): a "url" service runs out of band, and a
container consumer of one is rejected up front. A third resource, the probe
executable, hands the same inventory handle to Aspire's own withReference() and to
getServiceEndpoint(), and prints what each injected — so it shows as Exited, not Running, and
those two log lines are where you see both the native service-discovery path and the
portable endpoint accessor working. (Note: this
sample needs Aspire CLI 13.5.3 or newer — see the compatibility note below the code block.)
cd samples/DemoAppHostTypeScript
npm install
cp servicesources.local.json.example servicesources.local.json
aspire restore
aspire run
Requires Aspire CLI 13.5.3+: the CLI pins its own Aspire version for the host project it
generates, so a CLI older than this package's Aspire floor (13.5.2) fails aspire restore with
NU1605: Detected package downgrade: Aspire.Hosting from 13.5.2 to 13.5.1 before codegen even runs.
13.5.3 is the first release that pins high enough. On it, the generated SDK type-checks clean under
strict tsc and the sample runs end-to-end — withReference() on the addService() result injects
the resolved service's discovery variables into the consuming resource, e.g.
services__inventory__http__0=http://inventory.dev.internal:80 pointing at the running inventory
container.
This sample used to require an unreleased 13.6.0, and that requirement is gone. Aspire's TypeScript
codegen does not emit a *Promise/*PromiseImpl wrapper pair for a bare Aspire interface
(IResourceBuilder<IResourceWithServiceDiscovery>, which is what AddService returns), so the
generated SDK referenced an undeclared ResourceWithServiceDiscoveryPromise and failed with six
TS2552 errors — reported as
microsoft/aspire#19507 and fixed upstream by
microsoft/aspire#19577 under the 13.6 milestone.
That upstream fix is no longer what makes this work. The generator emits the wrapper pair when the
bare interface appears as an extension-method receiver rather than only as a return type, and
the ten [AspireExport] configuration shims above declare exactly that receiver — so they carry
the wrapper pair for addService too. Removing [AspireExport] from those shims brings all
six errors back on a current CLI, which is how the cause was isolated; the measurement is in
docs/superpowers/specs/2026-08-30-19507-already-fixed-findings.md.
Switching between CLI builds can leave a stale code generator under .aspire/, so remove that
directory before regenerating:
microsoft/aspire#19603.
When configuration is wrong
Every problem this package detects — a missing project file, an unregistered kind, a checkout it
won't overwrite, a clone it can't authenticate — is raised as a ServiceSourcesConfigurationException
whose message names the service, what failed, and what to do about it. Because these are raised
from AddService(), they usually reach you as an unhandled exception that takes the AppHost down
before Aspire starts, so that message is the error output. It prints as the message plus one
line per underlying cause:
Unhandled exception. Service 'reportdata': failed to clone repository 'https://github.com/acme/reportdata' into
'/src/report-service/src/Report.AppHost/.servicesources/checkouts/reportdata' — authentication failed, or the
repository is not visible to the credentials in use. Configure credentials via a git credential helper (`git
credential fill` must resolve them for this host) or the SERVICESOURCES_GIT_USERNAME/SERVICESOURCES_GIT_TOKEN/
SERVICESOURCES_GIT_HOST environment variables.
caused by: unexpected http status code: 404
(set SERVICESOURCES_FULL_ERRORS=1 for the full exception detail, including stack traces)
The stack frames behind it are this package's own plumbing and don't help with a misconfiguration,
so they're left out — and the last line says how to get them back, because for a failure this
package didn't anticipate they are the diagnosis. When you need them — you suspect a bug in this package rather than in your
configuration, and want to file it — set SERVICESOURCES_FULL_ERRORS=1 to get the runtime's
complete dump, type names, inner-exception blocks, stack traces and all.
Status
Early stage, evolving fast. "local", "kubernetes", "url", and "container" sources are
all implemented — see docs/superpowers/ for design and implementation
history, including the phase 2 backlog (repo auto-update, config discovery walk-up,
dependency/infrastructure resolution, and more).
Changes are recorded in CHANGELOG.md; how a release is cut is in
RELEASING.md; the trust model a resolved service runs under is in
SECURITY.md.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net8.0 is compatible. net8.0-android was computed. net8.0-browser was computed. net8.0-ios was computed. net8.0-maccatalyst was computed. net8.0-macos was computed. net8.0-tvos was computed. net8.0-windows was computed. net9.0 is compatible. net9.0-android was computed. net9.0-browser was computed. net9.0-ios was computed. net9.0-maccatalyst was computed. net9.0-macos was computed. net9.0-tvos was computed. net9.0-windows was computed. 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.2)
- YamlDotNet (>= 18.1.0)
-
net8.0
- Aspire.Hosting (>= 13.5.2)
- YamlDotNet (>= 18.1.0)
-
net9.0
- Aspire.Hosting (>= 13.5.2)
- YamlDotNet (>= 18.1.0)
NuGet packages (2)
Showing the top 2 NuGet packages that depend on KoalaSoft.Aspire.Hosting.ServiceSources:
| Package | Downloads |
|---|---|
|
KoalaSoft.Aspire.Hosting.ServiceSources.Java
Java support for KoalaSoft.Aspire.Hosting.ServiceSources — lets an AddService() "local" source clone and run a Java service (Maven goal, Gradle task, or a jar) via the .NET Aspire Community Toolkit's Java integration. |
|
|
KoalaSoft.Aspire.Hosting.ServiceSources.JavaScript
JavaScript support for KoalaSoft.Aspire.Hosting.ServiceSources — runs a "local"-sourced service with kind "javascript" through Aspire.Hosting.JavaScript. |
GitHub repositories
This package is not used by any popular GitHub repositories.
_There is no 0.5.0. That tag's release failed to publish to nuget.org and GitHub's immutable-release
protection then blocked reusing the name, so this version carries what would have been 0.5.0._
### Breaking
- **`ILocalResourceKind.Validate` takes the service's resolved checkout directory**
(#63). The signature gains a `repoRoot` parameter in the position `Resolve` already has it:
```csharp
public void Validate(string serviceName, object? rawConfig) // before
public void Validate(string serviceName, string repoRoot, object? rawConfig) // after
```
**Nothing fails to build, so read this even though your AppHost still compiles.** `Validate` is
a defaulted interface member: a kind still declaring the old two-parameter method compiles clean
against the new interface, it simply stops implementing it, and core calls the do-nothing default
in its place. Every rejection that method made would quietly stop running, and the typo'd options
block it used to name would reach `Resolve` and surface as "the handler failed while creating its
resource" instead. There is no compiler diagnostic for that, so `AddLocalKind` now refuses a
handler that declares a `Validate` not matching the interface member, naming the kind and the
method it found. Registering the kind is what tells you; the build will not.
To migrate, add the parameter. A kind that only parsed its options block needs nothing else. A
kind that never implemented `Validate` at all is unaffected — the default stands, and the check
above says nothing about it, as it does not about a migrated kind that keeps an old-shaped method
of its own. `Resolve`, `SupportsDeferredCheckout` and `ResolveDeferred` are untouched.
**A second silent change, and this one has no registration-time refusal to catch it: `Validate`
is no longer called for a service on the deferred path.** It is paired with `Resolve`, which core
does not call there either — under
[`UseDeferredCheckout()`](README.md#first-run-usedeferredcheckout) there is no checkout for it to
judge the service against, so `ResolveDeferred` is called instead. **If your kind can answer
`true` from `SupportsDeferredCheckout` and validates its options block only in `Validate`, that
block stops being validated at all for a deferred service.** Parse and reject it from
`ResolveDeferred` too, and hand the working-tree checks back as
`DeferredLocalResource.ValidateCheckout` as before. No trip wire is possible for this one:
implementing both `Validate` and `ResolveDeferred` is the ordinary, correct arrangement — the
built-in `java` kind is one — so anything detectable here would fire on working code. Saying it
is the only warning there is. Both shipped kinds already validate in `ResolveDeferred` and are
unaffected; a kind that leaves `SupportsDeferredCheckout` at its `false` default never reaches
this path at all.
What the parameter buys is the check a kind could not make before: `repoRoot` is the same
directory `Resolve` is about to get, already cloned and checked out, so a `workingDirectory`,
an entry-point file or a lockfile that the repository doesn't actually have can be reported from
`Validate` — where nothing of the service is in the app model yet — instead of from halfway
through building the resource. That is what the built-in `dotnet` kind has always done with its
`project` file, and core's "report it from `ILocalResourceKind.Validate` instead" message is now
advice a handler author can act on.
Handing it a checkout that has to be there is also what drops `Validate` off the deferred path
above, and it moves the call: core runs `Validate` after resolving the checkout rather than
before. That changes when a bad options block is reported, for **every** `"local"` service and
not just the first — see the **Changed** entry below. The "the handler failed while creating its
resource" wrapper on the deferred path now points at `DeferredLocalResource.ValidateCheckout`
rather than at `Validate`, which core does not call there.
- **The `.JavaScript` and `.Java` satellite packages are gone. Their kinds ship in
`KoalaSoft.Aspire.Hosting.ServiceSources`, and your AppHost references Aspire's hosting
package for the language directly** (#187). To migrate, drop the satellite, reference core,
and add the hosting package you actually use:
```bash
dotnet remove package KoalaSoft.Aspire.Hosting.ServiceSources.JavaScript
dotnet add package KoalaSoft.Aspire.Hosting.ServiceSources
dotnet add package Aspire.Hosting.JavaScript
```
| `kind` | Package to reference | Minimum |
| --- | --- | --- |
| `java` | `CommunityToolkit.Aspire.Hosting.Java` | 13.3.0 |
| `javascript` | `Aspire.Hosting.JavaScript` | 13.5.2 |
Nothing in `servicesources.yaml`, `servicesources.local.json`, the options blocks or the public
API changes: `UseJavaScript()` and `UseJava()` keep their names, signatures and namespace, and a
guest-language AppHost keeps `useJavaScript()`/`useJava()`. Only where they come from changes.
Leaving a satellite reference in place does not keep working — it resolves a core that no longer
has a satellite to pair with. There is nothing to keep in step any more, though: the `NU1107`
lockstep between core and a satellite (#79) is gone with them, and the version of the hosting
package is yours to pick at or above the minimum above.
The dependency isolation the satellites existed for (#89) is unchanged, and is now enforced
without a package: core compiles against both hosting packages but references them with
`PrivateAssets="all"`, so they appear in no nuspec and an AppHost declaring no `javascript`
service inherits neither that package nor the Aspire floor it carries. Assemblies resolve
per-method at JIT time, so the assembly is needed only when a service of that kind actually
resolves.
Forget one and you are told which. A referenced version below the minimum fails the build,
naming the package and the version that resolved — `SERVICESOURCES001` for
`Aspire.Hosting.JavaScript`, `SERVICESOURCES002` for `CommunityToolkit.Aspire.Hosting.Java`. A
prerelease of the minimum counts as below it, since a preview cut before that release carries
its assembly version and would otherwise bind and then fail on a missing member. The check
reaches a package reached transitively as well as a direct one. Missing entirely, the first
`AddService()` for a service of that kind fails with a message naming the package to install —
which is the only report a guest-language AppHost gets, since it consumes core through a project
reference the Aspire CLI generates and that imports no build-time checks. Where the package
arrived transitively and neither remedy is yours to apply, `ServiceSourcesSkipGuestLanguageFloorCheck=true`
turns the build-time check off for that project and leaves the run-time report standing.
- **`SERVICESOURCES_GIT_TOKEN` is now scoped to one host, and requires
`SERVICESOURCES_GIT_HOST` to be set at all** (#223). The environment-token credential helper
used to answer every `git credential fill` request it was consulted for, regardless of which
host git was asking about — a catalog listing services from more than one host would offer this
token to all of them, not just the one it was meant for. It now reads the host git asks about
and only answers when it matches `SERVICESOURCES_GIT_HOST` (host, and port if the URL has one —
the same `host:port` shape `git credential` itself uses, case-insensitively).
**This breaks a deployment that relies on `SERVICESOURCES_GIT_TOKEN` alone.** Without
`SERVICESOURCES_GIT_HOST` the helper now answers nothing, where it previously answered
everything — the safe default, not the compatible one. To migrate, set
`SERVICESOURCES_GIT_HOST` to the host (and port) the token is for, e.g.
`git.internal.example` or `git.internal.example:8443`. `SERVICESOURCES_GIT_USERNAME` is
unaffected; nothing changes for a repository resolved through your own git credential helper
(Git Credential Manager, `osxkeychain`, `libsecret`, ...) rather than this environment-variable
rung.
### Added
- **A backing service can be reached in a dev cluster: `source: "kubernetes"`** (#144). The
database, broker or cache a service connects to now has the third source the service side has had
all along — a `kubectl port-forward` this AppHost opens and Aspire manages, with the connection
string addressing its local end:
```jsonc
{
"backingServices": {
"orders-db": {
"source": "kubernetes",
"kubernetes": {
"service": "orders-pg-rw",
"port": 5432,
"context": "dev-west",
"namespace": "orders",
"connectionString": "Host=localhost;Port=${port};Database=orders;Username=dev;Password=hunter2"
}
}
}
}
```
The AppHost's own code is unchanged — the same `AddBackingService("orders-db", local: …)` call,
the same handle, and the same `ConnectionStrings__orders-db` the app reads. The `kubectl` process
appears in the dashboard as `orders-db-tunnel`, beneath the backing service it serves, and carries
kubectl's output.
**`WaitFor` on a backing service means something again under this source.** The local end of the
tunnel carries a TCP health check, and `WaitFor` waits for running *and* healthy — so a consumer
holds back until something is actually listening. Without it the connection-string resource
reports running as soon as its template resolves, which is immediately: measured against a tunnel
that took 8 seconds to come up, the consumer started at 3.4s rather than 11.5s, about five seconds
before anything was there to connect to. It is part of the source rather than an option, since a
wait that silently does nothing is worse than no wait.
**Write `${port}`, not a number**, and a connection string that names none is refused at startup —
with one exception, added below in the same release: a template that is exactly one `${secret:…}`
carries a port already.
The local port is allocated so that two forwarded backing services cannot collide, so it is not a
number anyone can write down — and the failure the refusal prevents is silent: `Port=5432` copied
out of a manifest addresses that port on the developer's own machine, where their own database
container may well be listening, and the AppHost would connect to the wrong database with every
resource reporting healthy. The message names the shell as well as the spelling, because a
template whose `${port}` was expanded away before the AppHost ran — `${…}` is a shell variable
too, and double quotes do not protect it — arrives looking exactly like one that never had it.
**Several ports go through one tunnel** (#233). `port` takes either a number or a block that
names each port, and a connection string reaches a named one as `${port:<name>}`:
```jsonc
"orders-events": {
"source": "kubernetes",
"kubernetes": {
"service": "rabbitmq",
"port": { "amqp": 5672, "management": 15672 },
"context": "dev-west",
"connectionString": "amqp://dev:hunter2@localhost:${port:amqp}/"
}
}
```
One `kubectl` invocation carries every pair, because it can — measured against a two-port Service
in `kind`, where one process forwarded both and carried real traffic on each. Two entries would
mean two processes and two tunnels to the same Service. Each forwarded port gets its own health
check, and all of them hang off the backing service, so a `WaitFor` waits for the whole tunnel
rather than for whichever port happened to be registered.
A single `port` is unchanged, and `${port}` remains its spelling. The two do not mix: `${port}`
against a block that names its ports is refused naming the ports it forwards, and `${port:<name>}`
against a single port is refused saying so — each with the spelling that entry actually takes,
rather than advice that would earn a second startup failure contradicting the first.
- **A connection string can read its credentials out of a Kubernetes secret: `${secret:<name>:<key>}`**
(#144). The password a backing service needs no longer has to be written into
`servicesources.local.json` beside the host and the port:
```jsonc
{
"backingServices": {
"orders-db": {
"source": "kubernetes",
"kubernetes": {
"service": "orders-pg-rw",
"port": 5432,
"context": "dev-west",
"namespace": "orders",
"connectionString": "Host=localhost;Port=${port};Database=orders;Username=dev;Password=${secret:orders-pg-app:password}"
}
}
}
}
```
**The fetch is deferred, not synchronous.** Each placeholder becomes a parameter whose value is
read when something first asks for it, rather than while the AppHost is being composed — so a
developer who has not logged in to the cluster yet gets one failed parameter in the dashboard
instead of an AppHost that will not start, and nothing runs `kubectl` on the path that local
project resolution was deliberately moved off. The value is marked secret, so the dashboard masks
it. Reading the same placeholder twice fetches once.
**A secret holding the whole connection string works too**, which is the shape a hand-authored
Sealed Secret usually has — there are no per-field keys to fall back on, and re-shaping one means
re-sealing against the cluster's key and a commit to a repo a platform team often owns. Write the
template as exactly one placeholder:
```jsonc
{
"backingServices": {
"orders-db": {
"source": "kubernetes",
"kubernetes": {
"service": "orders-pg-rw",
"port": 5432,
"context": "dev-west",
"namespace": "orders",
"connectionString": "${secret:orders-cs:connectionString}"
}
}
}
}
```
Then the port-forward listens on the *same* port `port` names rather than an allocated one,
because there is nothing in the template to substitute a local port into, and the in-cluster host
the secret was written against — `orders-pg-rw`, `.orders`, `.svc`, or the fully qualified
`.svc.cluster.local` — is rewritten to `localhost`. Giving up the allocated port is the real cost
of the mode, so a local port already in use is refused by name up front rather than left to the
tunnel's log — and, since there is then only the one number to match against, this mode is refused
against a block that names its ports the same way `${port}` is: give it a single `port` instead.
Two more things are refused rather than served quietly: a secret whose own port is not the one
being forwarded — the tunnel follows `port`, not the secret, and unchecked the app would dial a
port nothing serves while every resource reported healthy — and a secret that names the service in
no form this can rewrite, which would otherwise reach the app still addressed at the cluster.
Per-field placeholders stay preferred wherever the secret offers them.
- **`prepare` — a `"local"` checkout can bootstrap itself before its kind judges it** (#118). A
managed checkout is assumed to be runnable the moment it is cloned, which is not true of a
repository whose runnable artifact or data asset is produced by a script it commits and then
gitignores. Such a checkout resolved cleanly and then failed, and nothing in the catalog could ask
the repository to produce what it is perfectly capable of producing:
```yaml
services:
routing:
repository: https://github.com/example/routing
kind: java
prepare:
command: ["./prepare.sh"]
windowsCommand: ["pwsh", "-File", "prepare.ps1"] # optional; replaces command on Windows
mode: oncePerCommit # the default | once | always | never
java:
jarPath: graphhopper-web-11.0.jar
port: 8989
```
The command runs inside the materialized checkout, with the checkout as its working directory and
no shell in between, at the one point that works: after the working tree is complete and
reconciled onto its configured `ref`, and before the kind is allowed to judge it — so a kind
cannot reject a checkout for missing precisely the files the step was about to produce. It sits
there on both resolution paths, and no kind knows it exists. `command` is a list rather than a
string, so there is nothing to quote; a first element that looks like a path is confined to the
checkout, and a bare name goes through `PATH`.
**It runs once, not per start.** A hash of the resolved command and the commit it ran against is
recorded — only on success — at `<checkout>/.git/servicesources-prepare.json`, which is invisible
to the service repository's `git status` and dies with the checkout, so a deleted-and-recloned
checkout re-prepares. `mode` picks how coarse the guard is, and the choice between `once` and
`oncePerCommit` is the question "does the repository define this step?" rather than "how often":
a bootstrap whose script the repository commits wants `oncePerCommit`, so a team bumping it
reaches every developer, while one pinned by the catalog wants `once`, so a one-line README commit
doesn't cost a four-minute graph import. `always` is for a command that decides its own work,
which is what this delegates incremental rebuild to rather than approximating it. **Whatever the
mode, the command has to be safe to re-run**: nothing is recorded for a step that failed halfway,
so the next start runs it again against a checkout holding whatever the first attempt produced.
On the run that creates the checkout under `UseDeferredCheckout()`, the step's output streams into
the service's own resource log and the service carries a **Preparing** state while it runs, so a
country-sized import reads as an initialization phase rather than as a hang — and a failure there
costs that one service rather than the AppHost. Every other run reports to the AppHost's standard
output — under `aspire run`, the documented way to start an AppHost, that means the CLI's own log
under `~/.aspire/logs/` rather than the dashboard — **and a capped copy, its first and last lines
with anything dropped between them marked as elided, also lands in the service's resource log**
once the dashboard exists (#214), so the record still ends up where the service is even on a
run the console alone would have hidden it from. **Ctrl-C during a cold checkout's bootstrap
reaches the command's own process tree** on the run that creates it under
`UseDeferredCheckout()`, so interrupting a long import there ends it rather than leaving it, and
its children, running with no AppHost to belong to — composition has no token to hand the eager
path, the one most AppHosts take by default, so Ctrl-C during every other run's bootstrap leaves
the command orphaned instead (#279). There is no timeout: a legitimate bootstrap can take an
hour.
**`aspire publish` does not run it.** Publish composes the model, writes the manifest and exits,
and a bootstrap produces what a service needs in order to *run* — so nothing in a manifest depends
on it, and paying a multi-gigabyte download on every CI publish to emit one would be pure cost.
The block is still validated there, so a typo'd mode or a command pointing outside the checkout
still fails a publish; only the execution is gated. The skip is reported, because it has one
consequence worth naming: the step runs *before* the kind judges the checkout, so a service whose
committed files are not enough for its kind on their own — a generated `.csproj`, a generated
project directory — is reported as missing them. Run the AppHost once, then publish.
A service resolved through `local.path` **never inherits the catalog's block**: nothing establishes
that the directory is even a checkout of the repository the catalog names, and it is the
developer's own working tree. The catalog's block is ignored rather than rejected — it is the
team's field and correct for every developer on a managed checkout — and a startup notice names
the command that was not run, verbatim, so it can be pasted into `servicesources.local.json`. Any
block declared there silences it, `{"mode": "never"}` included. That file can also override a
catalog step per developer: `mode` on its own, or the `command`/`windowsCommand` pair replaced
together.
Not included, deliberately: no task runner, no ordering between steps, no cross-developer caching
of what a step produced, no timeout, and no injected environment variables. One command, one
marker, per service. See the
[`prepare` section](README.md#prepare-a-checkout-that-has-to-bootstrap-itself).
- **`AddBackingService()` — the database, broker or cache a service connects to, source-switched
the same way the service is** (#144). A service usually depends on a database, and a developer
wants the same choice for it that they have for the service: run it locally, or connect to the one
already running. The AppHost declares it once, and each developer decides in their own
`servicesources.local.json`:
```csharp
var ordersDb = builder.AddBackingService("orders-db",
local: () => builder.AddPostgres("orders-pg").AddDatabase("orders-db", "orders"));
builder.AddService("orders")
.Configure<IResourceWithEnvironment>(r => r.WithReference(ordersDb))
.Configure<IResourceWithWaitSupport>(r => r.WaitFor(ordersDb));
```
**Known limitation: that `WaitFor` stops meaning anything under `"direct"`** (#220). It waits
properly under `"local"`, where a real database resource sits behind it. Under `"direct"` the
resource is a connection string, which Aspire marks running as soon as the string is available, so
the wait is satisfied at once and the consumer starts without the instance you pointed at having
been checked. It does not hang and nothing fails; the ordering simply stops being enforced.
Two sources ship in this release, configured under a new `backingServices:` section read through
the same configuration layers as `services:`. `"local"` runs the factory the AppHost supplied and
returns its result unchanged — it is the default, so a backing service with no entry runs as the
AppHost reads. `"direct"` connects to a `direct.connectionString` the developer supplies, which
covers both a database they started by hand and a cluster database published through an ingress:
from the AppHost's side both are an address with no process to manage. A `"kubernetes"` source
that opens a `kubectl port-forward` is the next stage of the same issue and is not in this
release.
There is no catalog side to this. A backing service is declared by the `AddBackingService()` call
itself, so `servicesources.yaml` is untouched — and an AppHost that only connects to a database
needs no catalog file at all. Local provisioning stays the AppHost's own code, because
`builder.AddPostgres(...)` already expresses it better than catalog fields would; that also means
it works unchanged for anything carrying a connection string, `AddRabbitMQ` and `AddRedis`
included.
**The resource your local factory returns must be named after the backing service**, and
`AddBackingService` refuses it otherwise (#200). Aspire's `WithReference(...)` keys the
connection string on the referenced resource's own name, which under `"local"` is whatever the
factory built — so `() => builder.AddPostgres("pg").AddDatabase("orders")` behind a backing
service called `orders-db` would give a consumer `ConnectionStrings__orders` locally and
`ConnectionStrings__orders-db` under `"direct"`, moving the key the app reads when the developer
switches, with only the app to report it. `AddDatabase("orders-db", "orders")` names the resource
and the database separately. Casing counts: .NET folds it when reading configuration, but the
environment variable itself does not, and this package runs JavaScript and Java services, where
`process.env` and `System.getenv` are case-sensitive.
A consumer that needs a particular key can still pin it from its own side —
`WithReference(ordersDb, "OrdersDb")` gives `ConnectionStrings__OrdersDb` under every source. That
is not a way around the rule: the exported shim takes the source alone, so a guest-language AppHost
has no such argument (#209). That is this package's own gap and not a limit of guest languages —
a project's `withReference` already accepts `{ connectionName }` from TypeScript — but until the
shim offers the same, renaming the factory's resource is the one remedy every AppHost has, which
is why it is the one enforced.
**Write `direct.connectionString` as an address reached from outside Aspire.** It is handed on as
written; nothing about it is rewritten. The case that catches people out is pointing `"direct"` at
a container the same AppHost runs: the host and port the dashboard and `aspire describe` report
for a container endpoint belong to Aspire's endpoint proxy, which lives only as long as that
AppHost and is reassigned on the next start — not to the container's own published port.
`direct.connectionString` is normally a literal, and **braces reserve nothing** — `Driver={PostgreSQL}`,
`Server={host}\instance` and `PWD={secret}` all reach the app exactly as written, doubled braces
included, so ODBC's own doubling rule stays intact: `PWD={pa}}ss}` is the password `pa}ss` and
remains it. Placeholders open on `${`, which no connection-string dialect uses. `${port}` and
`${secret:<name>:<key>}` are recognised, reserved for the sources that can resolve them, and
rejected under `"direct"` with a message saying why; a malformed one fails at startup naming the
backing service and the key, quoting the spelling you wrote, rather than reaching the app as text.
A `${` begins a placeholder only when the word after it — up to the first `:` or `}`, or to the
end — is *exactly* `port` or `secret`, in any casing. Equality rather than a prefix, so
`${portal}`, `${secretariat}`, `${secrets:a}` and `${DB_PASS}` are text, which keeps a connection
string working when something else in your toolchain expands `${…}`. What stays reserved is
`${port}` and `${secret:…}` themselves, which cannot be written as literal text and have no
escape; `$` is not otherwise special, so `$${port}` is available as one if anything ever needs it
(#207).
**Setting a template through an environment variable needs single quotes.** `${…}` is also what a
POSIX shell, docker-compose and a GitHub Actions `run:` block expand, and double quotes do not
protect it — they cover the `;` and not the `${`. A template that loses its placeholder that way
arrives as a valid template with no placeholder in it, which nothing can report. The file,
appsettings and user secrets are unaffected, and are where a template normally lives.
A `source` of nothing but whitespace is refused rather than read as the default, the same way a
whitespace *field* has been refused since `0.4.0` and for the same reason: it is the empty
spelling that unsets a key, missed by a character. This also applies to a service's `source`,
which previously reported having no source configured without mentioning the spaces that caused
it.
**Configuration that nothing reads is reported** (#206). A backing service with no entry
legitimately runs locally, so an entry whose key matches no `AddBackingService()` call cannot be
told apart at read time from one that was never written — a typo in the *key* (`orders_db` against
`orders-db`) would silently revert that backing service to `"local"`, starting the container the
developer was trying to avoid. Once the AppHost is composed the declared names are known, so this
is warned about at startup, naming the entry and the declared name it resembles. A misspelled
`backingServices` root key, which does the same to every backing service at once, is warned about
the same way. Warnings rather than errors, because a shared file may legitimately carry entries
for backing services only some configurations add; there is no opt-out.
**The `services:` side of the same problem is warned about too** (#215). A `services` entry
naming no service `servicesources.yaml` declares used to be silently skipped — a typo one edit from
a real service name (`planning-fronend` against `planning-frontend`) bound, validated, and was
never looked up, with nothing to say so. Reported at the same `BeforeStartEvent` point as the
backing-service check above, naming the entry and the catalog name it resembles. A misspelled
`services` root key is warned about the same way. Left out on purpose: an entry the catalog *does*
declare but that no `AddService()` call adds is not silent today — `LocalCheckoutPrefetch` already
reports the cost of cloning it speculatively — so that half stays with the over-cloning work it
belongs to (#217). Warnings rather than errors, and again no opt-out, for the same reason as the
backing-service side.
- **A service whose resource never runs is reported in the AppHost's own console** (#150). A
`"local"` checkout that fails to compile used to produce nothing there at all: Aspire's build of
a checkout is `dotnet run`'s own, so the compiler's output goes to that resource's console in the
dashboard, and from the terminal the service simply never appeared. There is now one line per
failing resource, naming the service, naming the state Aspire reported for it, and pointing at
the dashboard:
```text
fail: Aspire.Hosting.ServiceSources[0]
Service 'orders' is configured as 'local' and its resource is not running: it reported
'Finished' with exit code 1. This console does not carry that resource's output, so
nothing here says why — its own console in the Aspire dashboard does, at the dashboard
URL logged above. …
```
Read off the resource's state rather than off any one failure path, so it covers a build that
won't compile, a deferred clone that never landed, and a service of any source — not `"local"`
alone. Reported for `FailedToStart` and for a terminal state with a non-zero exit code, one line
per failing replica.
It errs towards silence rather than towards a false alarm, since a channel that sometimes lies is
one developers learn to ignore. Not reported: a terminal state whose exit code was never
reported, an orderly Ctrl-C, a resource stopped from the dashboard, and `RuntimeUnhealthy` —
which names an unreachable container runtime rather than a failed service, and which an AppHost
started before its container runtime is up reports for every container-backed service before
starting them all normally. It says only *that* the service isn't running: the output that says
why belongs to the process Aspire launched, whose streams this package does not own. Needs no
opt-in, and nothing about how a failure reaches the dashboard changes.
- **`withServiceConnectionString` takes a `connectionName`** (#209). A guest-language AppHost
previously had no way to choose the environment variable a connection string arrives under — the
exported shim's `WithReference` had no such argument, so the key always followed the referenced
resource's own name. The only remedy was renaming that resource, which is not always available:
an app whose configuration already reads a particular name, or a resource that is not the
caller's to rename, had nothing to reach for. TypeScript now takes the same argument a project's
`withReference` already does:
```typescript
service.withServiceConnectionString(db, { connectionName: 'OrdersDb' });
// ConnectionStrings__OrdersDb, regardless of db's own resource name
```
`connectionName` is optional and defaults to the source resource's own name, matching today's
behavior when omitted.
- **`withServiceHttpsEndpoint()` and `withServiceHttpEndpoint()`** (#208). The exported shim set
covered environment, references, waits and args, but nothing let a guest-language AppHost declare
an endpoint on the resolved service — every other shape has a TypeScript equivalent of
`Configure<T>`, this one didn't. It becomes load-bearing the moment a deferred checkout is the
default: Aspire reads endpoints from a launch profile while composing, and a first-run checkout
that hasn't landed on disk yet has no launch profile for it to read, so nothing declares the
endpoint unless the AppHost does.
```typescript
builder.addService('common-auth').withServiceHttpsEndpoint();
```
Like every shim here, it delegates to `Configure<IResourceWithEndpoints>`, so it inherits that
method's skip-and-warn behavior for free: safe to write unconditionally, since a service a
developer has switched to `url` or `kubernetes` in `servicesources.local.json` is skipped rather
than misconfigured.
### Changed
- **`javascript.appDirectory` and `javascript.scriptPath` are now confined to the checkout by the
same lexical check as `project`, `prepare.command` and every `java.*` path** (#235). They used
to run a resolved check of their own — `Path.GetFullPath` followed by a root-prefix comparison —
which disagreed with the lexical one in three ways, all now gone: an absolute value resolved and
was reported as merely "outside the checkout" rather than named as absolute; a path segment made
only of dots and spaces (see #241) was never checked here, so it reached these two paths where
every other confined path already refused it; and a value written with `\` separators was never
normalized, so `appDirectory: src\frontend` resolved to a single oddly-named directory on Linux
and macOS instead of `src/frontend`.
… truncated for nuget.org's 35000-character release notes limit. Full entry in CHANGELOG.md.
Full changelog: https://github.com/flojon/aspire-servicesources/blob/main/CHANGELOG.md