diff --git a/src/Aspire.Cli/Commands/InitCommand.cs b/src/Aspire.Cli/Commands/InitCommand.cs index 26f768debb4..2411e11c50f 100644 --- a/src/Aspire.Cli/Commands/InitCommand.cs +++ b/src/Aspire.Cli/Commands/InitCommand.cs @@ -18,6 +18,7 @@ using Aspire.Cli.Telemetry; using Aspire.Cli.Templating; using Aspire.Cli.Utils; +using Aspire.Hosting; using Aspire.Shared; namespace Aspire.Cli.Commands; @@ -279,10 +280,30 @@ private async Task DropCSharpSingleFileSkeletonAsync(DirectoryInfo workingD InteractionService.DisplayMessage(KnownEmojis.CheckMarkButton, "Created package sources file"); } - // Drop aspire.config.json - var configResult = DropAspireConfig(workingDirectory, "apphost.cs", language: null); + // Generate one set of ports so aspire.config.json (used by `aspire run`) and + // apphost.run.json (used by `dotnet run apphost.cs`) agree on the dashboard / + // OTLP / resource service endpoints. + var ports = AppHostProfilePortGenerator.Generate(Random.Shared); + + // Drop aspire.config.json. The returned ports are whatever ended up effective + // in aspire.config.json — newly generated, or pre-existing if the file already + // had a `profiles` section. Use the SAME ports for apphost.run.json so the two + // files always agree on dashboard / OTLP / resource service endpoints. + var (configResult, effectivePorts) = DropAspireConfig(workingDirectory, "apphost.cs", language: null, ports); + if (configResult != ExitCodeConstants.Success) + { + return configResult; + } + + // Drop apphost.run.json so `dotnet run apphost.cs` picks up the dashboard / + // OTLP / resource service env vars from the file-based launch profile. Without + // this file the AppHost crashes at startup because DashboardOptions validation + // requires ASPNETCORE_URLS and ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL to be set + // (these env vars are otherwise injected by the Aspire CLI when running via + // `aspire run`, but `dotnet run apphost.cs` does not go through that path). + DropAppHostRunJson(workingDirectory, effectivePorts); - return configResult; + return ExitCodeConstants.Success; } private async Task DropCSharpProjectSkeletonAsync(FileInfo solutionFile, CancellationToken cancellationToken) @@ -402,7 +423,7 @@ private async Task DropPolyglotSkeletonAsync(string languageId, DirectoryIn return ExitCodeConstants.Success; } - private int DropAspireConfig(DirectoryInfo directory, string appHostPath, string? language) + private (int ExitCode, AppHostProfilePorts EffectivePorts) DropAspireConfig(DirectoryInfo directory, string appHostPath, string? language, AppHostProfilePorts? ports = null) { var configPath = Path.Combine(directory.FullName, AspireConfigFile.FileName); @@ -426,7 +447,7 @@ private int DropAspireConfig(DirectoryInfo directory, string appHostPath, string { InteractionService.DisplayError($"Failed to parse existing {AspireConfigFile.FileName} at '{configPath}': {ex.Message}"); InteractionService.DisplayMessage(KnownEmojis.Warning, $"Fix or remove {AspireConfigFile.FileName} and re-run `aspire init`."); - return ExitCodeConstants.FailedToCreateNewProject; + return (ExitCodeConstants.FailedToCreateNewProject, default); } } } @@ -451,42 +472,193 @@ private int DropAspireConfig(DirectoryInfo directory, string appHostPath, string appHost["language"] = language; } - // Write default profiles with random ports for dashboard/OTLP/resource service. - // Matches the profile structure used by `aspire new` templates (see Templates/*/aspire.config.json). - // Normally scaffolding + codegen creates these, but our thin init skips scaffolding. - if (settings["profiles"] is null) + // Resolve the effective ports. Three cases: + // 1. profiles is null → write fresh profiles, return those ports + // 2. profiles exists and parses cleanly → adopt those ports, return them (so + // apphost.run.json stays in sync with what `aspire run` will use) + // 3. profiles exists but doesn't match the expected 6-port shape (user-customized + // or older format) → PRESERVE the existing profiles untouched and just generate + // fresh ports for apphost.run.json. This is strictly safer than overwriting, + // even if the two files end up disagreeing on dashboard ports — the user has + // already opted into a custom config and we shouldn't trash their data. + AppHostProfilePorts effectivePorts; + var existingProfilesObject = settings["profiles"] as JsonObject; + if (existingProfilesObject is not null && TryReadAppHostProfilePorts(existingProfilesObject, out var readPorts)) + { + effectivePorts = readPorts; + } + else if (existingProfilesObject is not null) + { + // Existing profiles can't be parsed into our expected shape — leave them alone + // and just generate fresh ports for apphost.run.json. We deliberately don't + // overwrite the user's customizations, even though it means the two files may + // bind to different dashboard URLs in this edge case. + effectivePorts = ports ?? AppHostProfilePortGenerator.Generate(Random.Shared); + } + else { - var ports = AppHostProfilePortGenerator.Generate(Random.Shared); + // Matches the profile structure used by `aspire new` templates (see Templates/*/aspire.config.json). + // Normally scaffolding + codegen creates these, but our thin init skips scaffolding. + effectivePorts = ports ?? AppHostProfilePortGenerator.Generate(Random.Shared); + // Two profiles (https + http) so `aspire run` can pick either based on user choice. + // Each carries the dashboard URL (applicationUrl) plus the OTLP and resource-service + // endpoint env vars consumed by DashboardOptionsValidator at AppHost startup. settings["profiles"] = new JsonObject { ["https"] = new JsonObject { - ["applicationUrl"] = $"https://localhost:{ports.DashboardHttpsPort};http://localhost:{ports.DashboardHttpPort}", + ["applicationUrl"] = $"https://localhost:{effectivePorts.DashboardHttpsPort};http://localhost:{effectivePorts.DashboardHttpPort}", ["environmentVariables"] = new JsonObject { - ["ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL"] = $"https://localhost:{ports.OtlpHttpsPort}", - ["ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL"] = $"https://localhost:{ports.ResourceServiceHttpsPort}" + [KnownConfigNames.DashboardOtlpGrpcEndpointUrl] = $"https://localhost:{effectivePorts.OtlpHttpsPort}", + [KnownConfigNames.ResourceServiceEndpointUrl] = $"https://localhost:{effectivePorts.ResourceServiceHttpsPort}" } }, ["http"] = new JsonObject { - ["applicationUrl"] = $"http://localhost:{ports.DashboardHttpPort}", + ["applicationUrl"] = $"http://localhost:{effectivePorts.DashboardHttpPort}", ["environmentVariables"] = new JsonObject { - ["ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL"] = $"http://localhost:{ports.OtlpHttpPort}", - ["ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL"] = $"http://localhost:{ports.ResourceServiceHttpPort}", - ["ASPIRE_ALLOW_UNSECURED_TRANSPORT"] = "true" + [KnownConfigNames.DashboardOtlpGrpcEndpointUrl] = $"http://localhost:{effectivePorts.OtlpHttpPort}", + [KnownConfigNames.ResourceServiceEndpointUrl] = $"http://localhost:{effectivePorts.ResourceServiceHttpPort}", + [KnownConfigNames.AllowUnsecuredTransport] = "true" } } }; } - var jsonOptions = new JsonSerializerOptions { WriteIndented = true }; - File.WriteAllText(configPath, settings.ToJsonString(jsonOptions)); + File.WriteAllText(configPath, JsonSerializer.Serialize(settings, JsonSourceGenerationContext.RelaxedEscaping.JsonObject)); InteractionService.DisplayMessage(KnownEmojis.CheckMarkButton, $"Created {AspireConfigFile.FileName}"); - return ExitCodeConstants.Success; + return (ExitCodeConstants.Success, effectivePorts); + } + + // Best-effort extraction of the dashboard / OTLP / resource service ports from an + // existing `profiles` section. Returns true only if every expected port can be parsed + // from the https + http profiles, otherwise the caller falls back to fresh ports. + private static bool TryReadAppHostProfilePorts(JsonObject profiles, out AppHostProfilePorts ports) + { + ports = default; + + if (profiles["https"] is not JsonObject https || profiles["http"] is not JsonObject http) + { + return false; + } + + var httpsEnv = https["environmentVariables"] as JsonObject; + var httpEnv = http["environmentVariables"] as JsonObject; + if (httpsEnv is null || httpEnv is null) + { + return false; + } + + if (!TryParseHostPort(https["applicationUrl"]?.GetValue(), "https", out var dashboardHttps) + || !TryParseHostPort(http["applicationUrl"]?.GetValue(), "http", out var dashboardHttp) + || !TryParseHostPort(httpsEnv[KnownConfigNames.DashboardOtlpGrpcEndpointUrl]?.GetValue(), "https", out var otlpHttps) + || !TryParseHostPort(httpEnv[KnownConfigNames.DashboardOtlpGrpcEndpointUrl]?.GetValue(), "http", out var otlpHttp) + || !TryParseHostPort(httpsEnv[KnownConfigNames.ResourceServiceEndpointUrl]?.GetValue(), "https", out var resourceServiceHttps) + || !TryParseHostPort(httpEnv[KnownConfigNames.ResourceServiceEndpointUrl]?.GetValue(), "http", out var resourceServiceHttp)) + { + return false; + } + + ports = new AppHostProfilePorts( + DashboardHttpsPort: dashboardHttps, + DashboardHttpPort: dashboardHttp, + OtlpHttpsPort: otlpHttps, + OtlpHttpPort: otlpHttp, + ResourceServiceHttpsPort: resourceServiceHttps, + ResourceServiceHttpPort: resourceServiceHttp); + return true; + } + + // Parses the first `://host:` segment from a (possibly semicolon- + // separated) URL list. Returns false if no segment with the requested scheme is found + // or the port can't be parsed. + private static bool TryParseHostPort(string? value, string scheme, out int port) + { + port = 0; + if (string.IsNullOrWhiteSpace(value)) + { + return false; + } + + foreach (var raw in value.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + if (!Uri.TryCreate(raw, UriKind.Absolute, out var uri)) + { + continue; + } + + if (string.Equals(uri.Scheme, scheme, StringComparison.OrdinalIgnoreCase) && uri.Port > 0) + { + port = uri.Port; + return true; + } + } + + return false; + } + + // Writes apphost.run.json next to the single-file AppHost so that + // `dotnet run apphost.cs` (.NET file-based runner) picks up the dashboard / OTLP / + // resource service launch profile env vars. Mirrors the structure shipped by the + // aspire-apphost-singlefile MSBuild template. Skips if the file already exists. + private void DropAppHostRunJson(DirectoryInfo directory, AppHostProfilePorts ports) + { + const string fileName = "apphost.run.json"; + var path = Path.Combine(directory.FullName, fileName); + if (File.Exists(path)) + { + return; + } + + // Shape mirrors a Properties/launchSettings.json (the schema the .NET file-based + // runner inherits for `[file].run.json`): a `profiles` map with `commandName: Project` + // entries. The https / http pair gives `dotnet run apphost.cs` a working dashboard + // URL plus the OTLP and resource-service endpoint env vars that DashboardOptionsValidator + // requires — without these the AppHost crashes at startup (see #15986). + var settings = new JsonObject + { + ["$schema"] = "https://json.schemastore.org/launchsettings.json", + ["profiles"] = new JsonObject + { + ["https"] = new JsonObject + { + ["commandName"] = "Project", + ["dotnetRunMessages"] = true, + ["launchBrowser"] = true, + ["applicationUrl"] = $"https://localhost:{ports.DashboardHttpsPort};http://localhost:{ports.DashboardHttpPort}", + ["environmentVariables"] = new JsonObject + { + ["ASPNETCORE_ENVIRONMENT"] = "Development", + ["DOTNET_ENVIRONMENT"] = "Development", + [KnownConfigNames.DashboardOtlpGrpcEndpointUrl] = $"https://localhost:{ports.OtlpHttpsPort}", + [KnownConfigNames.ResourceServiceEndpointUrl] = $"https://localhost:{ports.ResourceServiceHttpsPort}" + } + }, + ["http"] = new JsonObject + { + ["commandName"] = "Project", + ["dotnetRunMessages"] = true, + ["launchBrowser"] = true, + ["applicationUrl"] = $"http://localhost:{ports.DashboardHttpPort}", + ["environmentVariables"] = new JsonObject + { + ["ASPNETCORE_ENVIRONMENT"] = "Development", + ["DOTNET_ENVIRONMENT"] = "Development", + [KnownConfigNames.DashboardOtlpGrpcEndpointUrl] = $"http://localhost:{ports.OtlpHttpPort}", + [KnownConfigNames.ResourceServiceEndpointUrl] = $"http://localhost:{ports.ResourceServiceHttpPort}", + [KnownConfigNames.AllowUnsecuredTransport] = "true" + } + } + } + }; + + File.WriteAllText(path, JsonSerializer.Serialize(settings, JsonSourceGenerationContext.RelaxedEscaping.JsonObject)); + + InteractionService.DisplayMessage(KnownEmojis.CheckMarkButton, $"Created {fileName}"); } } diff --git a/tests/Aspire.Cli.EndToEnd.Tests/SingleFileAppHostInitDotnetRunTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/SingleFileAppHostInitDotnetRunTests.cs new file mode 100644 index 00000000000..0f8ad468896 --- /dev/null +++ b/tests/Aspire.Cli.EndToEnd.Tests/SingleFileAppHostInitDotnetRunTests.cs @@ -0,0 +1,116 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text.Json.Nodes; +using Aspire.Cli.EndToEnd.Tests.Helpers; +using Aspire.Cli.Tests.Utils; +using Hex1b.Automation; +using Hex1b.Input; +using Xunit; + +namespace Aspire.Cli.EndToEnd.Tests; + +/// +/// Regression test for https://github.com/microsoft/aspire/issues/15986. +/// +/// +/// +/// Verifies that after interactive aspire init's C# single-file path runs, +/// dotnet run apphost.cs can launch the AppHost successfully — i.e. the +/// launch-profile environment variables required by the dashboard / OTLP / resource +/// service are wired up correctly via the generated apphost.run.json. +/// +/// +/// Before the fix, aspire init wrote apphost.cs, aspire.config.json, +/// and NuGet.config but skipped apphost.run.json. The .NET 10 file-based +/// runner only honours [file].run.json for launch profiles (it ignores +/// aspire.config.json), so without it dotnet run apphost.cs crashed at +/// startup with OptionsValidationException complaining that +/// ASPNETCORE_URLS and ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL were not set. +/// +/// +public sealed class SingleFileAppHostInitDotnetRunTests(ITestOutputHelper output) +{ + [CaptureWorkspaceOnFailure] + [Fact] + public async Task AspireInitSingleFileAppHostRunsViaDotnetRunAppHost() + { + var repoRoot = CliE2ETestHelpers.GetRepoRoot(); + var strategy = CliInstallStrategy.Detect(output.WriteLine); + var workspace = TemporaryWorkspace.Create(output); + + using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, mountDockerSocket: false, workspace: workspace); + + var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); + + var counter = new SequenceCounter(); + var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); + + await auto.PrepareDockerEnvironmentAsync(counter, workspace); + await auto.InstallAspireCliAsync(strategy, counter); + + // Run aspire init without --language so the interactive language prompt is shown, + // then accept the default '> C#' selection. + await auto.TypeAsync("aspire init"); + await auto.EnterAsync(); + + await auto.WaitUntilAsync( + s => new CellPatternSearcher().Find("> C#").Search(s).Count > 0, + timeout: TimeSpan.FromSeconds(30), + description: "language selection prompt with default '> C#'"); + await auto.EnterAsync(); + + await auto.WaitUntilTextAsync("Created aspire.config.json", timeout: TimeSpan.FromMinutes(2)); + await auto.DeclineAgentInitPromptAsync(counter); + + // The workspace directory is bind-mounted into the container, so we can read + // the files generated by `aspire init` from the host side without leaving the + // shell session. + var appHostCs = Path.Combine(workspace.WorkspaceRoot.FullName, "apphost.cs"); + var aspireConfigJson = Path.Combine(workspace.WorkspaceRoot.FullName, "aspire.config.json"); + var appHostRunJson = Path.Combine(workspace.WorkspaceRoot.FullName, "apphost.run.json"); + + Assert.True(File.Exists(appHostCs), $"Expected apphost.cs to exist at: {appHostCs}"); + Assert.True(File.Exists(aspireConfigJson), $"Expected aspire.config.json to exist at: {aspireConfigJson}"); + Assert.True( + File.Exists(appHostRunJson), + $"Expected apphost.run.json to exist at: {appHostRunJson}. " + + "This is the regression tracked by https://github.com/microsoft/aspire/issues/15986: " + + "without it, `dotnet run apphost.cs` crashes at startup because the dashboard " + + "environment variables (ASPNETCORE_URLS, ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL) are unset."); + + // Sanity-check that the launch profile in apphost.run.json carries the dashboard + // env vars that the file-based runner needs to inject. Full schema is covered by + // InitCommand_SingleFileSkeleton_CreatesAppHostRunJsonWithDashboardEnvVars. + var runJson = JsonNode.Parse(File.ReadAllText(appHostRunJson))?.AsObject(); + Assert.NotNull(runJson); + var httpsProfile = runJson["profiles"]?["https"]?.AsObject(); + Assert.NotNull(httpsProfile); + Assert.Equal("Project", httpsProfile["commandName"]?.GetValue()); + Assert.False(string.IsNullOrWhiteSpace(httpsProfile["applicationUrl"]?.GetValue())); + var httpsEnv = httpsProfile["environmentVariables"]?.AsObject(); + Assert.NotNull(httpsEnv); + Assert.False(string.IsNullOrWhiteSpace(httpsEnv["ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL"]?.GetValue())); + Assert.False(string.IsNullOrWhiteSpace(httpsEnv["ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL"]?.GetValue())); + + // `dotnet run apphost.cs` should print "Distributed application started." once the + // AppHost is fully up. 1 minute is plenty — even a cold dotnet build of the bare + // single-file AppHost completes well inside that budget; if it hasn't started by + // then something is wrong (build failure, missing env var, hang) and we should + // fail fast rather than wait several minutes. + await auto.TypeAsync("dotnet run apphost.cs"); + await auto.EnterAsync(); + await auto.WaitUntilTextAsync( + "Distributed application started.", + timeout: TimeSpan.FromMinutes(1)); + + // Stop the running AppHost with Ctrl+C and wait for the shell prompt. + await auto.Ctrl().KeyAsync(Hex1bKey.C); + await auto.WaitForAnyPromptAsync(counter, TimeSpan.FromMinutes(1)); + + await auto.TypeAsync("exit"); + await auto.EnterAsync(); + await pendingRun; + } +} + diff --git a/tests/Aspire.Cli.Tests/Commands/InitCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/InitCommandTests.cs index cc6d0ad5d53..dccb566ba3f 100644 --- a/tests/Aspire.Cli.Tests/Commands/InitCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/InitCommandTests.cs @@ -162,6 +162,173 @@ public async Task InitCommand_WhenNoSolutionExists_CreatesSingleFileAppHostAndAs Assert.Null(appHost["language"]); } + [Fact] + public async Task InitCommand_SingleFileSkeleton_CreatesAppHostRunJsonWithDashboardEnvVars() + { + // Regression for https://github.com/microsoft/aspire/issues/15986: without + // apphost.run.json, `dotnet run apphost.cs` after `aspire init` crashes because + // the dashboard env vars (ASPNETCORE_URLS, ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL) + // are not set. Init must emit apphost.run.json alongside aspire.config.json so + // the file-based runner picks up a launch profile. + using var workspace = TemporaryWorkspace.Create(outputHelper); + + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); + var serviceProvider = services.BuildServiceProvider(); + var initCommand = serviceProvider.GetRequiredService(); + + var parseResult = initCommand.Parse("init"); + var exitCode = await parseResult.InvokeAsync().DefaultTimeout(); + + Assert.Equal(ExitCodeConstants.Success, exitCode); + + var runJsonPath = Path.Combine(workspace.WorkspaceRoot.FullName, "apphost.run.json"); + Assert.True(File.Exists(runJsonPath), "apphost.run.json should be created so `dotnet run apphost.cs` works."); + + var runJson = JsonNode.Parse(File.ReadAllText(runJsonPath))!.AsObject(); + var profiles = runJson["profiles"]!.AsObject(); + + var https = profiles["https"]!.AsObject(); + Assert.Equal("Project", https["commandName"]!.GetValue()); + Assert.True(https["dotnetRunMessages"]!.GetValue()); + var httpsUrls = https["applicationUrl"]!.GetValue(); + Assert.StartsWith("https://localhost:", httpsUrls); + var httpsEnv = https["environmentVariables"]!.AsObject(); + Assert.Equal("Development", httpsEnv["ASPNETCORE_ENVIRONMENT"]!.GetValue()); + Assert.Equal("Development", httpsEnv["DOTNET_ENVIRONMENT"]!.GetValue()); + Assert.StartsWith("https://localhost:", httpsEnv["ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL"]!.GetValue()); + Assert.StartsWith("https://localhost:", httpsEnv["ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL"]!.GetValue()); + + var http = profiles["http"]!.AsObject(); + Assert.Equal("Project", http["commandName"]!.GetValue()); + var httpEnv = http["environmentVariables"]!.AsObject(); + Assert.Equal("Development", httpEnv["ASPNETCORE_ENVIRONMENT"]!.GetValue()); + Assert.StartsWith("http://localhost:", httpEnv["ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL"]!.GetValue()); + Assert.StartsWith("http://localhost:", httpEnv["ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL"]!.GetValue()); + Assert.Equal("true", httpEnv["ASPIRE_ALLOW_UNSECURED_TRANSPORT"]!.GetValue()); + + // The two files must agree on ports — otherwise `aspire run` and + // `dotnet run apphost.cs` would bind to different dashboard URLs. + var aspireConfig = JsonNode.Parse(File.ReadAllText(Path.Combine(workspace.WorkspaceRoot.FullName, "aspire.config.json")))!.AsObject(); + var aspireProfiles = aspireConfig["profiles"]!.AsObject(); + Assert.Equal( + aspireProfiles["https"]!["applicationUrl"]!.GetValue(), + httpsUrls); + Assert.Equal( + aspireProfiles["http"]!["applicationUrl"]!.GetValue(), + http["applicationUrl"]!.GetValue()); + } + + [Fact] + public async Task InitCommand_SingleFileSkeleton_AppHostRunJsonAdoptsPortsFromExistingAspireConfig() + { + // If aspire.config.json already exists with a `profiles` section (e.g. user + // re-ran `aspire init` after editing it, or copied a stale file in), the new + // apphost.run.json must adopt those same ports — the two files should never + // disagree on dashboard / OTLP / resource service endpoints. + using var workspace = TemporaryWorkspace.Create(outputHelper); + + const string existingAspireConfig = """ + { + "appHost": { + "path": "apphost.cs" + }, + "profiles": { + "https": { + "applicationUrl": "https://localhost:18000;http://localhost:18001", + "environmentVariables": { + "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:18002", + "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:18003" + } + }, + "http": { + "applicationUrl": "http://localhost:18001", + "environmentVariables": { + "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:18005", + "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:18006", + "ASPIRE_ALLOW_UNSECURED_TRANSPORT": "true" + } + } + } + } + """; + File.WriteAllText(Path.Combine(workspace.WorkspaceRoot.FullName, "aspire.config.json"), existingAspireConfig); + + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); + var serviceProvider = services.BuildServiceProvider(); + var initCommand = serviceProvider.GetRequiredService(); + + var parseResult = initCommand.Parse("init"); + var exitCode = await parseResult.InvokeAsync().DefaultTimeout(); + + Assert.Equal(ExitCodeConstants.Success, exitCode); + + var runJson = JsonNode.Parse(File.ReadAllText(Path.Combine(workspace.WorkspaceRoot.FullName, "apphost.run.json")))!.AsObject(); + var profiles = runJson["profiles"]!.AsObject(); + var https = profiles["https"]!.AsObject(); + var http = profiles["http"]!.AsObject(); + + Assert.Equal("https://localhost:18000;http://localhost:18001", https["applicationUrl"]!.GetValue()); + Assert.Equal("https://localhost:18002", https["environmentVariables"]!["ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL"]!.GetValue()); + Assert.Equal("https://localhost:18003", https["environmentVariables"]!["ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL"]!.GetValue()); + Assert.Equal("http://localhost:18001", http["applicationUrl"]!.GetValue()); + Assert.Equal("http://localhost:18005", http["environmentVariables"]!["ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL"]!.GetValue()); + Assert.Equal("http://localhost:18006", http["environmentVariables"]!["ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL"]!.GetValue()); + } + + [Fact] + public async Task InitCommand_SingleFileSkeleton_PreservesUnparseableExistingProfiles() + { + // Regression guard for behavioral safety: if aspire.config.json already has a + // `profiles` section that doesn't match the expected 6-port shape (e.g. user-customized, + // missing one of the env vars, or an https-only setup), `aspire init` must NOT + // overwrite those profiles. The user has clearly customized their config and we + // shouldn't trash their data — even at the cost of apphost.run.json potentially + // binding to different dashboard ports. + using var workspace = TemporaryWorkspace.Create(outputHelper); + + const string customAspireConfig = """ + { + "appHost": { + "path": "apphost.cs" + }, + "profiles": { + "https": { + "applicationUrl": "https://localhost:18000", + "environmentVariables": { + "MY_CUSTOM_VAR": "custom-value" + } + } + } + } + """; + var aspireConfigPath = Path.Combine(workspace.WorkspaceRoot.FullName, "aspire.config.json"); + File.WriteAllText(aspireConfigPath, customAspireConfig); + + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); + var serviceProvider = services.BuildServiceProvider(); + var initCommand = serviceProvider.GetRequiredService(); + + var parseResult = initCommand.Parse("init"); + var exitCode = await parseResult.InvokeAsync().DefaultTimeout(); + + Assert.Equal(ExitCodeConstants.Success, exitCode); + + // The user's customizations must be preserved verbatim — only the appHost.path + // is allowed to be touched (since that's the primary purpose of DropAspireConfig). + var aspireConfig = JsonNode.Parse(File.ReadAllText(aspireConfigPath))!.AsObject(); + var preservedProfiles = aspireConfig["profiles"]!.AsObject(); + Assert.False(preservedProfiles.ContainsKey("http"), "http profile should NOT have been added."); + var preservedHttps = preservedProfiles["https"]!.AsObject(); + Assert.Equal("https://localhost:18000", preservedHttps["applicationUrl"]!.GetValue()); + var preservedEnv = preservedHttps["environmentVariables"]!.AsObject(); + Assert.Equal("custom-value", preservedEnv["MY_CUSTOM_VAR"]!.GetValue()); + Assert.False(preservedEnv.ContainsKey("ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL"), "OTLP env var should NOT have been added to user's custom profile."); + + // apphost.run.json still gets written so `dotnet run apphost.cs` works (with fresh + // ports — accepted divergence in this edge case). + Assert.True(File.Exists(Path.Combine(workspace.WorkspaceRoot.FullName, "apphost.run.json"))); + } + [Fact] public async Task InitCommand_WhenDeprecatedCompatibilityOptionsProvided_SucceedsAndWarns() {