Skip to content

Commit 4517e4a

Browse files
mitchdennyCopilot
andauthored
Fix #15986: emit apphost.run.json from aspire init single-file skeleton (#16812) (#16821)
* Add CLI E2E repro for #15986: dotnet run apphost.cs after aspire init Demonstrates that 'dotnet run apphost.cs' against the single-file C# AppHost dropped by interactive 'aspire init' fails because the launch profile env vars (ASPNETCORE_URLS, ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL, ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL) are missing — aspire init does not write apphost.run.json and the .NET file-based runner only honours that file (not aspire.config.json) for launch profiles. This commit intentionally adds a failing test; the fix follows in a subsequent commit on the same PR. * Fix #15986: emit apphost.run.json from aspire init single-file skeleton After 'aspire init' drops the C# single-file AppHost ('apphost.cs' + 'aspire.config.json' + 'NuGet.config'), running 'dotnet run apphost.cs' crashed at startup because no launch profile was applied: Failed to configure dashboard resource because ASPNETCORE_URLS environment variable was not set. Failed to configure dashboard resource because ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL and ASPIRE_DASHBOARD_OTLP_HTTP_ENDPOINT_URL are not set. The dashboard / OTLP / resource service env vars normally come from a launch profile. When the AppHost is launched via 'aspire run' the CLI injects them from 'aspire.config.json'. When launched via the .NET file-based runner ('dotnet run apphost.cs') only '<file>.run.json' (here 'apphost.run.json') is honoured, and the init flow was not producing it. Fix: in InitCommand.DropCSharpSingleFileSkeletonAsync, generate the profile ports once and pass them into both the existing 'aspire.config.json' writer and a new 'apphost.run.json' writer so the two files agree on the dashboard URLs. The 'apphost.run.json' shape mirrors the existing aspire-apphost-singlefile MSBuild template (commandName=Project, dotnetRunMessages=true, launchBrowser=true, ASPNETCORE_ENVIRONMENT/DOTNET_ENVIRONMENT=Development, dashboard / OTLP / resource service URLs). The aspire-apphost-singlefile MSBuild template is unaffected since it already ships 'apphost.run.json' alongside its other artifacts. No other call site of DropAspireConfig is changed (the new ports parameter is optional and defaults to the previous self-generation behaviour). Adds InitCommand_SingleFileSkeleton_CreatesAppHostRunJsonWithDashboardEnvVars unit test and updates the SingleFileAppHostInitDotnetRunTests E2E repro added in the previous commit. Fixes #15986 * Add CLI E2E test for #15986: aspire init then dotnet run apphost.cs Drives the user-facing flow that #15986 broke: 1. `aspire init` (interactive, default C# selection). 2. Inspect the bind-mounted workspace from the host: assert `apphost.cs`, `aspire.config.json`, and `apphost.run.json` all exist, and that `apphost.run.json`'s `https` profile carries the dashboard / OTLP / resource-service env vars (full schema is covered by `InitCommand_SingleFileSkeleton_CreatesAppHostRunJsonWithDashboardEnvVars`). 3. `dotnet run apphost.cs` and wait for `Distributed application started.`. 4. Ctrl+C and exit cleanly. Before the fix, `apphost.run.json` was never written, the precondition in step 2 fails fast with an explicit pointer to #15986, and step 3 would crash at startup with the dashboard `OptionsValidationException` about missing `ASPNETCORE_URLS` / `ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL` (verified on this branch — the test-only commit pushed earlier failed in CI with exactly that error before the fix landed in this PR). * Trigger CI revalidation (whitespace only) * Address PR feedback: shared JSON options + ports always agree James review feedback on #16812: 1. Use shared JsonSourceGenerationContext.RelaxedEscaping (which already has WriteIndented = true and UnsafeRelaxedJsonEscaping) instead of inline 'new JsonSerializerOptions { WriteIndented = true }' at both ToJsonString call sites in InitCommand. 2. Make divergence between aspire.config.json and apphost.run.json impossible by construction. DropAspireConfig now returns the effective ports (newly generated, or read back from a pre-existing profiles section), and DropCSharpSingleFileSkeletonAsync threads those into DropAppHostRunJson — so even if aspire.config.json was hand-edited or pre-existed, both files always describe the same dashboard / OTLP / resource service endpoints. Added unit test InitCommand_SingleFileSkeleton_AppHostRunJsonAdoptsPortsFromExistingAspireConfig that pre-seeds aspire.config.json with profiles and asserts apphost.run.json adopts those exact ports. Also dropped the E2E 'Distributed application started.' wait timeout from 7 minutes to 1 minute — even a cold dotnet build of the bare single-file AppHost completes well inside that budget; if it's not started by then, fail fast. * Strengthen inline comments at JSON literals (PR #16812 review nits) James left two nit comments asking for short explanatory comments at the JSON literal blocks. Beef up the comment at the apphost.run.json literal in DropAppHostRunJson explaining what shape it mirrors and why; the existing comment in DropAspireConfig now also calls out that each profile carries the dashboard URL plus the OTLP / resource-service env vars consumed by DashboardOptionsValidator at AppHost startup. * Address PR feedback: preserve user profiles + use KnownConfigNames Two more JamesNK review comments on PR #16812: 1. Behavioural regression (line 481): the previous patch overwrote any pre-existing 'profiles' section in aspire.config.json whenever TryReadAppHostProfilePorts couldn't parse all six expected ports (e.g. user-customised config, https-only setup, missing one of the env vars). The original implementation preserved existing profiles unconditionally — this is restoring that safety. New behaviour: - profiles is null -> write fresh, return those ports - profiles parses cleanly -> adopt those ports, return them (existing behaviour) - profiles exists but doesn't match the expected shape -> PRESERVE the user's profiles untouched, generate fresh ports just for apphost.run.json (accepted edge-case divergence — better than silent data loss) Added InitCommand_SingleFileSkeleton_PreservesUnparseableExistingProfiles to lock the preservation behaviour in. 2. Use KnownConfigNames constants (line 546): replaced literal 'ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL', 'ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL', 'ASPIRE_ALLOW_UNSECURED_TRANSPORT' strings throughout InitCommand.cs (in DropAspireConfig, DropAppHostRunJson, and TryReadAppHostProfilePorts) with KnownConfigNames.DashboardOtlpGrpcEndpointUrl / .ResourceServiceEndpointUrl / .AllowUnsecuredTransport. Test fixtures keep literal strings since they document the on-disk JSON shape from the user's perspective. --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent c8ddbaf commit 4517e4a

3 files changed

Lines changed: 475 additions & 20 deletions

File tree

src/Aspire.Cli/Commands/InitCommand.cs

Lines changed: 192 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
using Aspire.Cli.Telemetry;
1919
using Aspire.Cli.Templating;
2020
using Aspire.Cli.Utils;
21+
using Aspire.Hosting;
2122
using Aspire.Shared;
2223

2324
namespace Aspire.Cli.Commands;
@@ -279,10 +280,30 @@ private async Task<int> DropCSharpSingleFileSkeletonAsync(DirectoryInfo workingD
279280
InteractionService.DisplayMessage(KnownEmojis.CheckMarkButton, "Created package sources file");
280281
}
281282

282-
// Drop aspire.config.json
283-
var configResult = DropAspireConfig(workingDirectory, "apphost.cs", language: null);
283+
// Generate one set of ports so aspire.config.json (used by `aspire run`) and
284+
// apphost.run.json (used by `dotnet run apphost.cs`) agree on the dashboard /
285+
// OTLP / resource service endpoints.
286+
var ports = AppHostProfilePortGenerator.Generate(Random.Shared);
287+
288+
// Drop aspire.config.json. The returned ports are whatever ended up effective
289+
// in aspire.config.json — newly generated, or pre-existing if the file already
290+
// had a `profiles` section. Use the SAME ports for apphost.run.json so the two
291+
// files always agree on dashboard / OTLP / resource service endpoints.
292+
var (configResult, effectivePorts) = DropAspireConfig(workingDirectory, "apphost.cs", language: null, ports);
293+
if (configResult != ExitCodeConstants.Success)
294+
{
295+
return configResult;
296+
}
297+
298+
// Drop apphost.run.json so `dotnet run apphost.cs` picks up the dashboard /
299+
// OTLP / resource service env vars from the file-based launch profile. Without
300+
// this file the AppHost crashes at startup because DashboardOptions validation
301+
// requires ASPNETCORE_URLS and ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL to be set
302+
// (these env vars are otherwise injected by the Aspire CLI when running via
303+
// `aspire run`, but `dotnet run apphost.cs` does not go through that path).
304+
DropAppHostRunJson(workingDirectory, effectivePorts);
284305

285-
return configResult;
306+
return ExitCodeConstants.Success;
286307
}
287308

288309
private async Task<int> DropCSharpProjectSkeletonAsync(FileInfo solutionFile, CancellationToken cancellationToken)
@@ -402,7 +423,7 @@ private async Task<int> DropPolyglotSkeletonAsync(string languageId, DirectoryIn
402423
return ExitCodeConstants.Success;
403424
}
404425

405-
private int DropAspireConfig(DirectoryInfo directory, string appHostPath, string? language)
426+
private (int ExitCode, AppHostProfilePorts EffectivePorts) DropAspireConfig(DirectoryInfo directory, string appHostPath, string? language, AppHostProfilePorts? ports = null)
406427
{
407428
var configPath = Path.Combine(directory.FullName, AspireConfigFile.FileName);
408429

@@ -426,7 +447,7 @@ private int DropAspireConfig(DirectoryInfo directory, string appHostPath, string
426447
{
427448
InteractionService.DisplayError($"Failed to parse existing {AspireConfigFile.FileName} at '{configPath}': {ex.Message}");
428449
InteractionService.DisplayMessage(KnownEmojis.Warning, $"Fix or remove {AspireConfigFile.FileName} and re-run `aspire init`.");
429-
return ExitCodeConstants.FailedToCreateNewProject;
450+
return (ExitCodeConstants.FailedToCreateNewProject, default);
430451
}
431452
}
432453
}
@@ -451,42 +472,193 @@ private int DropAspireConfig(DirectoryInfo directory, string appHostPath, string
451472
appHost["language"] = language;
452473
}
453474

454-
// Write default profiles with random ports for dashboard/OTLP/resource service.
455-
// Matches the profile structure used by `aspire new` templates (see Templates/*/aspire.config.json).
456-
// Normally scaffolding + codegen creates these, but our thin init skips scaffolding.
457-
if (settings["profiles"] is null)
475+
// Resolve the effective ports. Three cases:
476+
// 1. profiles is null → write fresh profiles, return those ports
477+
// 2. profiles exists and parses cleanly → adopt those ports, return them (so
478+
// apphost.run.json stays in sync with what `aspire run` will use)
479+
// 3. profiles exists but doesn't match the expected 6-port shape (user-customized
480+
// or older format) → PRESERVE the existing profiles untouched and just generate
481+
// fresh ports for apphost.run.json. This is strictly safer than overwriting,
482+
// even if the two files end up disagreeing on dashboard ports — the user has
483+
// already opted into a custom config and we shouldn't trash their data.
484+
AppHostProfilePorts effectivePorts;
485+
var existingProfilesObject = settings["profiles"] as JsonObject;
486+
if (existingProfilesObject is not null && TryReadAppHostProfilePorts(existingProfilesObject, out var readPorts))
487+
{
488+
effectivePorts = readPorts;
489+
}
490+
else if (existingProfilesObject is not null)
491+
{
492+
// Existing profiles can't be parsed into our expected shape — leave them alone
493+
// and just generate fresh ports for apphost.run.json. We deliberately don't
494+
// overwrite the user's customizations, even though it means the two files may
495+
// bind to different dashboard URLs in this edge case.
496+
effectivePorts = ports ?? AppHostProfilePortGenerator.Generate(Random.Shared);
497+
}
498+
else
458499
{
459-
var ports = AppHostProfilePortGenerator.Generate(Random.Shared);
500+
// Matches the profile structure used by `aspire new` templates (see Templates/*/aspire.config.json).
501+
// Normally scaffolding + codegen creates these, but our thin init skips scaffolding.
502+
effectivePorts = ports ?? AppHostProfilePortGenerator.Generate(Random.Shared);
460503

504+
// Two profiles (https + http) so `aspire run` can pick either based on user choice.
505+
// Each carries the dashboard URL (applicationUrl) plus the OTLP and resource-service
506+
// endpoint env vars consumed by DashboardOptionsValidator at AppHost startup.
461507
settings["profiles"] = new JsonObject
462508
{
463509
["https"] = new JsonObject
464510
{
465-
["applicationUrl"] = $"https://localhost:{ports.DashboardHttpsPort};http://localhost:{ports.DashboardHttpPort}",
511+
["applicationUrl"] = $"https://localhost:{effectivePorts.DashboardHttpsPort};http://localhost:{effectivePorts.DashboardHttpPort}",
466512
["environmentVariables"] = new JsonObject
467513
{
468-
["ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL"] = $"https://localhost:{ports.OtlpHttpsPort}",
469-
["ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL"] = $"https://localhost:{ports.ResourceServiceHttpsPort}"
514+
[KnownConfigNames.DashboardOtlpGrpcEndpointUrl] = $"https://localhost:{effectivePorts.OtlpHttpsPort}",
515+
[KnownConfigNames.ResourceServiceEndpointUrl] = $"https://localhost:{effectivePorts.ResourceServiceHttpsPort}"
470516
}
471517
},
472518
["http"] = new JsonObject
473519
{
474-
["applicationUrl"] = $"http://localhost:{ports.DashboardHttpPort}",
520+
["applicationUrl"] = $"http://localhost:{effectivePorts.DashboardHttpPort}",
475521
["environmentVariables"] = new JsonObject
476522
{
477-
["ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL"] = $"http://localhost:{ports.OtlpHttpPort}",
478-
["ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL"] = $"http://localhost:{ports.ResourceServiceHttpPort}",
479-
["ASPIRE_ALLOW_UNSECURED_TRANSPORT"] = "true"
523+
[KnownConfigNames.DashboardOtlpGrpcEndpointUrl] = $"http://localhost:{effectivePorts.OtlpHttpPort}",
524+
[KnownConfigNames.ResourceServiceEndpointUrl] = $"http://localhost:{effectivePorts.ResourceServiceHttpPort}",
525+
[KnownConfigNames.AllowUnsecuredTransport] = "true"
480526
}
481527
}
482528
};
483529
}
484530

485-
var jsonOptions = new JsonSerializerOptions { WriteIndented = true };
486-
File.WriteAllText(configPath, settings.ToJsonString(jsonOptions));
531+
File.WriteAllText(configPath, JsonSerializer.Serialize(settings, JsonSourceGenerationContext.RelaxedEscaping.JsonObject));
487532

488533
InteractionService.DisplayMessage(KnownEmojis.CheckMarkButton, $"Created {AspireConfigFile.FileName}");
489-
return ExitCodeConstants.Success;
534+
return (ExitCodeConstants.Success, effectivePorts);
535+
}
536+
537+
// Best-effort extraction of the dashboard / OTLP / resource service ports from an
538+
// existing `profiles` section. Returns true only if every expected port can be parsed
539+
// from the https + http profiles, otherwise the caller falls back to fresh ports.
540+
private static bool TryReadAppHostProfilePorts(JsonObject profiles, out AppHostProfilePorts ports)
541+
{
542+
ports = default;
543+
544+
if (profiles["https"] is not JsonObject https || profiles["http"] is not JsonObject http)
545+
{
546+
return false;
547+
}
548+
549+
var httpsEnv = https["environmentVariables"] as JsonObject;
550+
var httpEnv = http["environmentVariables"] as JsonObject;
551+
if (httpsEnv is null || httpEnv is null)
552+
{
553+
return false;
554+
}
555+
556+
if (!TryParseHostPort(https["applicationUrl"]?.GetValue<string>(), "https", out var dashboardHttps)
557+
|| !TryParseHostPort(http["applicationUrl"]?.GetValue<string>(), "http", out var dashboardHttp)
558+
|| !TryParseHostPort(httpsEnv[KnownConfigNames.DashboardOtlpGrpcEndpointUrl]?.GetValue<string>(), "https", out var otlpHttps)
559+
|| !TryParseHostPort(httpEnv[KnownConfigNames.DashboardOtlpGrpcEndpointUrl]?.GetValue<string>(), "http", out var otlpHttp)
560+
|| !TryParseHostPort(httpsEnv[KnownConfigNames.ResourceServiceEndpointUrl]?.GetValue<string>(), "https", out var resourceServiceHttps)
561+
|| !TryParseHostPort(httpEnv[KnownConfigNames.ResourceServiceEndpointUrl]?.GetValue<string>(), "http", out var resourceServiceHttp))
562+
{
563+
return false;
564+
}
565+
566+
ports = new AppHostProfilePorts(
567+
DashboardHttpsPort: dashboardHttps,
568+
DashboardHttpPort: dashboardHttp,
569+
OtlpHttpsPort: otlpHttps,
570+
OtlpHttpPort: otlpHttp,
571+
ResourceServiceHttpsPort: resourceServiceHttps,
572+
ResourceServiceHttpPort: resourceServiceHttp);
573+
return true;
574+
}
575+
576+
// Parses the first `<scheme>://host:<port>` segment from a (possibly semicolon-
577+
// separated) URL list. Returns false if no segment with the requested scheme is found
578+
// or the port can't be parsed.
579+
private static bool TryParseHostPort(string? value, string scheme, out int port)
580+
{
581+
port = 0;
582+
if (string.IsNullOrWhiteSpace(value))
583+
{
584+
return false;
585+
}
586+
587+
foreach (var raw in value.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
588+
{
589+
if (!Uri.TryCreate(raw, UriKind.Absolute, out var uri))
590+
{
591+
continue;
592+
}
593+
594+
if (string.Equals(uri.Scheme, scheme, StringComparison.OrdinalIgnoreCase) && uri.Port > 0)
595+
{
596+
port = uri.Port;
597+
return true;
598+
}
599+
}
600+
601+
return false;
602+
}
603+
604+
// Writes apphost.run.json next to the single-file AppHost so that
605+
// `dotnet run apphost.cs` (.NET file-based runner) picks up the dashboard / OTLP /
606+
// resource service launch profile env vars. Mirrors the structure shipped by the
607+
// aspire-apphost-singlefile MSBuild template. Skips if the file already exists.
608+
private void DropAppHostRunJson(DirectoryInfo directory, AppHostProfilePorts ports)
609+
{
610+
const string fileName = "apphost.run.json";
611+
var path = Path.Combine(directory.FullName, fileName);
612+
if (File.Exists(path))
613+
{
614+
return;
615+
}
616+
617+
// Shape mirrors a Properties/launchSettings.json (the schema the .NET file-based
618+
// runner inherits for `[file].run.json`): a `profiles` map with `commandName: Project`
619+
// entries. The https / http pair gives `dotnet run apphost.cs` a working dashboard
620+
// URL plus the OTLP and resource-service endpoint env vars that DashboardOptionsValidator
621+
// requires — without these the AppHost crashes at startup (see #15986).
622+
var settings = new JsonObject
623+
{
624+
["$schema"] = "https://json.schemastore.org/launchsettings.json",
625+
["profiles"] = new JsonObject
626+
{
627+
["https"] = new JsonObject
628+
{
629+
["commandName"] = "Project",
630+
["dotnetRunMessages"] = true,
631+
["launchBrowser"] = true,
632+
["applicationUrl"] = $"https://localhost:{ports.DashboardHttpsPort};http://localhost:{ports.DashboardHttpPort}",
633+
["environmentVariables"] = new JsonObject
634+
{
635+
["ASPNETCORE_ENVIRONMENT"] = "Development",
636+
["DOTNET_ENVIRONMENT"] = "Development",
637+
[KnownConfigNames.DashboardOtlpGrpcEndpointUrl] = $"https://localhost:{ports.OtlpHttpsPort}",
638+
[KnownConfigNames.ResourceServiceEndpointUrl] = $"https://localhost:{ports.ResourceServiceHttpsPort}"
639+
}
640+
},
641+
["http"] = new JsonObject
642+
{
643+
["commandName"] = "Project",
644+
["dotnetRunMessages"] = true,
645+
["launchBrowser"] = true,
646+
["applicationUrl"] = $"http://localhost:{ports.DashboardHttpPort}",
647+
["environmentVariables"] = new JsonObject
648+
{
649+
["ASPNETCORE_ENVIRONMENT"] = "Development",
650+
["DOTNET_ENVIRONMENT"] = "Development",
651+
[KnownConfigNames.DashboardOtlpGrpcEndpointUrl] = $"http://localhost:{ports.OtlpHttpPort}",
652+
[KnownConfigNames.ResourceServiceEndpointUrl] = $"http://localhost:{ports.ResourceServiceHttpPort}",
653+
[KnownConfigNames.AllowUnsecuredTransport] = "true"
654+
}
655+
}
656+
}
657+
};
658+
659+
File.WriteAllText(path, JsonSerializer.Serialize(settings, JsonSourceGenerationContext.RelaxedEscaping.JsonObject));
660+
661+
InteractionService.DisplayMessage(KnownEmojis.CheckMarkButton, $"Created {fileName}");
490662
}
491663

492664
}

0 commit comments

Comments
 (0)