From 320c1d75261327a4be489a9928300a6786f81fd2 Mon Sep 17 00:00:00 2001 From: Eric Erhardt Date: Thu, 30 Apr 2026 18:07:53 -0500 Subject: [PATCH 1/2] Fix TypeScript AppHost generated port ranges Use a shared AppHost profile port generator for CLI templates, init, and TypeScript AppHost scaffolding so generated dashboard and service profile ports avoid the Windows ephemeral range. Add regression coverage for the generated TypeScript apphost.run.json ports. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Cli/Aspire.Cli.csproj | 1 + src/Aspire.Cli/Commands/InitCommand.cs | 21 ++++----- .../Templating/CliTemplateFactory.cs | 26 ++++------- ...e.Hosting.CodeGeneration.TypeScript.csproj | 1 + .../TypeScriptLanguageSupport.cs | 11 ++--- src/Shared/AppHostProfilePortGenerator.cs | 42 ++++++++++++++++++ .../TypeScriptLanguageSupportTests.cs | 43 +++++++++++++++++++ 7 files changed, 107 insertions(+), 38 deletions(-) create mode 100644 src/Shared/AppHostProfilePortGenerator.cs diff --git a/src/Aspire.Cli/Aspire.Cli.csproj b/src/Aspire.Cli/Aspire.Cli.csproj index aec002100aa..2cb7933cc3c 100644 --- a/src/Aspire.Cli/Aspire.Cli.csproj +++ b/src/Aspire.Cli/Aspire.Cli.csproj @@ -152,6 +152,7 @@ + diff --git a/src/Aspire.Cli/Commands/InitCommand.cs b/src/Aspire.Cli/Commands/InitCommand.cs index e643237737f..e816f491db4 100644 --- a/src/Aspire.Cli/Commands/InitCommand.cs +++ b/src/Aspire.Cli/Commands/InitCommand.cs @@ -15,6 +15,7 @@ using Aspire.Cli.Scaffolding; using Aspire.Cli.Telemetry; using Aspire.Cli.Utils; +using Aspire.Shared; namespace Aspire.Cli.Commands; @@ -398,32 +399,26 @@ private int DropAspireConfig(DirectoryInfo directory, string appHostPath, string // Normally scaffolding + codegen creates these, but our thin init skips scaffolding. if (settings["profiles"] is null) { - // Port ranges match CliTemplateFactory.GenerateRandomPorts() - var httpPort = Random.Shared.Next(15000, 15300); - var httpsPort = Random.Shared.Next(17000, 17300); - var otlpHttpPort = Random.Shared.Next(19000, 19300); - var otlpHttpsPort = Random.Shared.Next(21000, 21300); - var resourceHttpPort = Random.Shared.Next(20000, 20300); - var resourceHttpsPort = Random.Shared.Next(22000, 22300); + var ports = AppHostProfilePortGenerator.Generate(Random.Shared); settings["profiles"] = new JsonObject { ["https"] = new JsonObject { - ["applicationUrl"] = $"https://localhost:{httpsPort};http://localhost:{httpPort}", + ["applicationUrl"] = $"https://localhost:{ports.DashboardHttpsPort};http://localhost:{ports.DashboardHttpPort}", ["environmentVariables"] = new JsonObject { - ["ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL"] = $"https://localhost:{otlpHttpsPort}", - ["ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL"] = $"https://localhost:{resourceHttpsPort}" + ["ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL"] = $"https://localhost:{ports.OtlpHttpsPort}", + ["ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL"] = $"https://localhost:{ports.ResourceServiceHttpsPort}" } }, ["http"] = new JsonObject { - ["applicationUrl"] = $"http://localhost:{httpPort}", + ["applicationUrl"] = $"http://localhost:{ports.DashboardHttpPort}", ["environmentVariables"] = new JsonObject { - ["ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL"] = $"http://localhost:{otlpHttpPort}", - ["ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL"] = $"http://localhost:{resourceHttpPort}", + ["ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL"] = $"http://localhost:{ports.OtlpHttpPort}", + ["ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL"] = $"http://localhost:{ports.ResourceServiceHttpPort}", ["ASPIRE_ALLOW_UNSECURED_TRANSPORT"] = "true" } } diff --git a/src/Aspire.Cli/Templating/CliTemplateFactory.cs b/src/Aspire.Cli/Templating/CliTemplateFactory.cs index 82f84dfed17..098592a8848 100644 --- a/src/Aspire.Cli/Templating/CliTemplateFactory.cs +++ b/src/Aspire.Cli/Templating/CliTemplateFactory.cs @@ -10,6 +10,7 @@ using Aspire.Cli.Resources; using Aspire.Cli.Scaffolding; using Aspire.Cli.Utils; +using Aspire.Shared; using Microsoft.Extensions.Logging; namespace Aspire.Cli.Templating; @@ -164,37 +165,26 @@ private bool IsTemplateAvailable(ITemplate template) return _languageDiscovery.GetLanguageById(new LanguageId(template.LanguageId)) is not null; } - private static string ApplyTokens(string content, string projectName, string projectNameLower, string aspireVersion, TemplatePorts ports, string hostName = "localhost") + private static string ApplyTokens(string content, string projectName, string projectNameLower, string aspireVersion, AppHostProfilePorts ports, string hostName = "localhost") { return content .Replace("{{projectName}}", projectName) .Replace("{{projectNameLower}}", projectNameLower) .Replace("{{aspireVersion}}", aspireVersion) .Replace("{{hostName}}", hostName) - .Replace("{{httpPort}}", ports.HttpPort.ToString(CultureInfo.InvariantCulture)) - .Replace("{{httpsPort}}", ports.HttpsPort.ToString(CultureInfo.InvariantCulture)) + .Replace("{{httpPort}}", ports.DashboardHttpPort.ToString(CultureInfo.InvariantCulture)) + .Replace("{{httpsPort}}", ports.DashboardHttpsPort.ToString(CultureInfo.InvariantCulture)) .Replace("{{otlpHttpPort}}", ports.OtlpHttpPort.ToString(CultureInfo.InvariantCulture)) .Replace("{{otlpHttpsPort}}", ports.OtlpHttpsPort.ToString(CultureInfo.InvariantCulture)) - .Replace("{{resourceHttpPort}}", ports.ResourceHttpPort.ToString(CultureInfo.InvariantCulture)) - .Replace("{{resourceHttpsPort}}", ports.ResourceHttpsPort.ToString(CultureInfo.InvariantCulture)); + .Replace("{{resourceHttpPort}}", ports.ResourceServiceHttpPort.ToString(CultureInfo.InvariantCulture)) + .Replace("{{resourceHttpsPort}}", ports.ResourceServiceHttpsPort.ToString(CultureInfo.InvariantCulture)); } - private static TemplatePorts GenerateRandomPorts() + private static AppHostProfilePorts GenerateRandomPorts() { - return new TemplatePorts( - HttpPort: Random.Shared.Next(15000, 15300), - HttpsPort: Random.Shared.Next(17000, 17300), - OtlpHttpPort: Random.Shared.Next(19000, 19300), - OtlpHttpsPort: Random.Shared.Next(21000, 21300), - ResourceHttpPort: Random.Shared.Next(20000, 20300), - ResourceHttpsPort: Random.Shared.Next(22000, 22300)); + return AppHostProfilePortGenerator.Generate(Random.Shared); } - private sealed record TemplatePorts( - int HttpPort, int HttpsPort, - int OtlpHttpPort, int OtlpHttpsPort, - int ResourceHttpPort, int ResourceHttpsPort); - private static void AddOptionIfMissing(System.CommandLine.Command command, System.CommandLine.Option option) { if (!command.Options.Contains(option)) diff --git a/src/Aspire.Hosting.CodeGeneration.TypeScript/Aspire.Hosting.CodeGeneration.TypeScript.csproj b/src/Aspire.Hosting.CodeGeneration.TypeScript/Aspire.Hosting.CodeGeneration.TypeScript.csproj index 44bd6c969fb..57d44d78a9d 100644 --- a/src/Aspire.Hosting.CodeGeneration.TypeScript/Aspire.Hosting.CodeGeneration.TypeScript.csproj +++ b/src/Aspire.Hosting.CodeGeneration.TypeScript/Aspire.Hosting.CodeGeneration.TypeScript.csproj @@ -25,6 +25,7 @@ + diff --git a/src/Aspire.Hosting.CodeGeneration.TypeScript/TypeScriptLanguageSupport.cs b/src/Aspire.Hosting.CodeGeneration.TypeScript/TypeScriptLanguageSupport.cs index 2e22ceb629b..003382c0750 100644 --- a/src/Aspire.Hosting.CodeGeneration.TypeScript/TypeScriptLanguageSupport.cs +++ b/src/Aspire.Hosting.CodeGeneration.TypeScript/TypeScriptLanguageSupport.cs @@ -120,19 +120,16 @@ public Dictionary Scaffold(ScaffoldRequest request) ? new Random(request.PortSeed.Value) : Random.Shared; - var httpsPort = random.Next(10000, 65000); - var httpPort = random.Next(10000, 65000); - var otlpPort = random.Next(10000, 65000); - var resourceServicePort = random.Next(10000, 65000); + var ports = AppHostProfilePortGenerator.Generate(random); files["apphost.run.json"] = $$""" { "profiles": { "https": { - "applicationUrl": "https://localhost:{{httpsPort}};http://localhost:{{httpPort}}", + "applicationUrl": "https://localhost:{{ports.DashboardHttpsPort}};http://localhost:{{ports.DashboardHttpPort}}", "environmentVariables": { - "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:{{otlpPort}}", - "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:{{resourceServicePort}}" + "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:{{ports.OtlpHttpsPort}}", + "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:{{ports.ResourceServiceHttpsPort}}" } } } diff --git a/src/Shared/AppHostProfilePortGenerator.cs b/src/Shared/AppHostProfilePortGenerator.cs new file mode 100644 index 00000000000..df09c5e2492 --- /dev/null +++ b/src/Shared/AppHostProfilePortGenerator.cs @@ -0,0 +1,42 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Aspire.Shared; + +internal static class AppHostProfilePortGenerator +{ + internal const int WindowsEphemeralPortMin = 49152; + internal const int DashboardHttpPortMin = 15000; + internal const int DashboardHttpPortMaxExclusive = 15300; + internal const int DashboardHttpsPortMin = 17000; + internal const int DashboardHttpsPortMaxExclusive = 17300; + internal const int OtlpHttpPortMin = 19000; + internal const int OtlpHttpPortMaxExclusive = 19300; + internal const int OtlpHttpsPortMin = 21000; + internal const int OtlpHttpsPortMaxExclusive = 21300; + internal const int ResourceServiceHttpPortMin = 20000; + internal const int ResourceServiceHttpPortMaxExclusive = 20300; + internal const int ResourceServiceHttpsPortMin = 22000; + internal const int ResourceServiceHttpsPortMaxExclusive = 22300; + + internal static AppHostProfilePorts Generate(Random random) + { + ArgumentNullException.ThrowIfNull(random); + + return new AppHostProfilePorts( + DashboardHttpPort: random.Next(DashboardHttpPortMin, DashboardHttpPortMaxExclusive), + DashboardHttpsPort: random.Next(DashboardHttpsPortMin, DashboardHttpsPortMaxExclusive), + OtlpHttpPort: random.Next(OtlpHttpPortMin, OtlpHttpPortMaxExclusive), + OtlpHttpsPort: random.Next(OtlpHttpsPortMin, OtlpHttpsPortMaxExclusive), + ResourceServiceHttpPort: random.Next(ResourceServiceHttpPortMin, ResourceServiceHttpPortMaxExclusive), + ResourceServiceHttpsPort: random.Next(ResourceServiceHttpsPortMin, ResourceServiceHttpsPortMaxExclusive)); + } +} + +internal readonly record struct AppHostProfilePorts( + int DashboardHttpPort, + int DashboardHttpsPort, + int OtlpHttpPort, + int OtlpHttpsPort, + int ResourceServiceHttpPort, + int ResourceServiceHttpsPort); diff --git a/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/TypeScriptLanguageSupportTests.cs b/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/TypeScriptLanguageSupportTests.cs index adaab97bdf7..57171c42ba0 100644 --- a/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/TypeScriptLanguageSupportTests.cs +++ b/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/TypeScriptLanguageSupportTests.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Text.Json.Nodes; +using Aspire.Shared; using Aspire.TypeSystem; namespace Aspire.Hosting.CodeGeneration.TypeScript.Tests; @@ -187,6 +188,40 @@ public void Scaffold_DoesNotEmitRootTsConfig_WhenOneAlreadyExists() Assert.Equal(existingTsConfig, File.ReadAllText(existingTsConfigPath)); } + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(16626)] + [InlineData(55571)] + public void Scaffold_GeneratesProfilePortsOutsideWindowsEphemeralRange(int portSeed) + { + using var testDir = new TestTempDirectory(); + + var files = _languageSupport.Scaffold(new ScaffoldRequest + { + TargetPath = testDir.Path, + ProjectName = "PortsApp", + PortSeed = portSeed + }); + + var appHostRunJson = ParseJson(files["apphost.run.json"]); + var httpsProfile = appHostRunJson["profiles"]!["https"]!.AsObject(); + var applicationUrls = httpsProfile["applicationUrl"]!.GetValue().Split(';', StringSplitOptions.RemoveEmptyEntries); + var environmentVariables = httpsProfile["environmentVariables"]!.AsObject(); + + Assert.Equal(2, applicationUrls.Length); + + var httpsPort = GetPort(applicationUrls.Single(url => url.StartsWith("https://", StringComparison.OrdinalIgnoreCase))); + var httpPort = GetPort(applicationUrls.Single(url => url.StartsWith("http://", StringComparison.OrdinalIgnoreCase))); + var otlpHttpsPort = GetPort(environmentVariables["ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL"]!.GetValue()); + var resourceServiceHttpsPort = GetPort(environmentVariables["ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL"]!.GetValue()); + + AssertPortInRange(httpPort, AppHostProfilePortGenerator.DashboardHttpPortMin, AppHostProfilePortGenerator.DashboardHttpPortMaxExclusive); + AssertPortInRange(httpsPort, AppHostProfilePortGenerator.DashboardHttpsPortMin, AppHostProfilePortGenerator.DashboardHttpsPortMaxExclusive); + AssertPortInRange(otlpHttpsPort, AppHostProfilePortGenerator.OtlpHttpsPortMin, AppHostProfilePortGenerator.OtlpHttpsPortMaxExclusive); + AssertPortInRange(resourceServiceHttpsPort, AppHostProfilePortGenerator.ResourceServiceHttpsPortMin, AppHostProfilePortGenerator.ResourceServiceHttpsPortMaxExclusive); + } + [Fact] public void GetRuntimeSpec_UsesAppHostSpecificTsConfig() { @@ -198,4 +233,12 @@ public void GetRuntimeSpec_UsesAppHostSpecificTsConfig() } private static JsonObject ParseJson(string content) => JsonNode.Parse(content)!.AsObject(); + + private static int GetPort(string url) => new Uri(url).Port; + + private static void AssertPortInRange(int port, int minInclusive, int maxExclusive) + { + Assert.InRange(port, minInclusive, maxExclusive - 1); + Assert.True(port < AppHostProfilePortGenerator.WindowsEphemeralPortMin, $"Expected port {port} to be below the Windows ephemeral range."); + } } From 30afac2a4a1514e4186cabb4c336a5a3a05e5568 Mon Sep 17 00:00:00 2001 From: Eric Erhardt Date: Fri, 1 May 2026 12:00:16 -0500 Subject: [PATCH 2/2] PR feedback --- src/Aspire.Cli/Aspire.Cli.csproj | 2 +- .../Aspire.Hosting.CodeGeneration.TypeScript.csproj | 2 +- src/Shared/AppHostProfilePortGenerator.cs | 1 - .../TypeScriptLanguageSupportTests.cs | 7 +++++-- 4 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/Aspire.Cli/Aspire.Cli.csproj b/src/Aspire.Cli/Aspire.Cli.csproj index 2cb7933cc3c..b29804475cb 100644 --- a/src/Aspire.Cli/Aspire.Cli.csproj +++ b/src/Aspire.Cli/Aspire.Cli.csproj @@ -93,6 +93,7 @@ + @@ -152,7 +153,6 @@ - diff --git a/src/Aspire.Hosting.CodeGeneration.TypeScript/Aspire.Hosting.CodeGeneration.TypeScript.csproj b/src/Aspire.Hosting.CodeGeneration.TypeScript/Aspire.Hosting.CodeGeneration.TypeScript.csproj index 57d44d78a9d..68bd97d52c9 100644 --- a/src/Aspire.Hosting.CodeGeneration.TypeScript/Aspire.Hosting.CodeGeneration.TypeScript.csproj +++ b/src/Aspire.Hosting.CodeGeneration.TypeScript/Aspire.Hosting.CodeGeneration.TypeScript.csproj @@ -24,8 +24,8 @@ - + diff --git a/src/Shared/AppHostProfilePortGenerator.cs b/src/Shared/AppHostProfilePortGenerator.cs index df09c5e2492..f9516b5d734 100644 --- a/src/Shared/AppHostProfilePortGenerator.cs +++ b/src/Shared/AppHostProfilePortGenerator.cs @@ -5,7 +5,6 @@ namespace Aspire.Shared; internal static class AppHostProfilePortGenerator { - internal const int WindowsEphemeralPortMin = 49152; internal const int DashboardHttpPortMin = 15000; internal const int DashboardHttpPortMaxExclusive = 15300; internal const int DashboardHttpsPortMin = 17000; diff --git a/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/TypeScriptLanguageSupportTests.cs b/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/TypeScriptLanguageSupportTests.cs index 57171c42ba0..1b016e144bc 100644 --- a/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/TypeScriptLanguageSupportTests.cs +++ b/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/TypeScriptLanguageSupportTests.cs @@ -189,11 +189,12 @@ public void Scaffold_DoesNotEmitRootTsConfig_WhenOneAlreadyExists() } [Theory] + [InlineData(null)] [InlineData(0)] [InlineData(1)] [InlineData(16626)] [InlineData(55571)] - public void Scaffold_GeneratesProfilePortsOutsideWindowsEphemeralRange(int portSeed) + public void Scaffold_GeneratesProfilePortsOutsideWindowsEphemeralRange(int? portSeed) { using var testDir = new TestTempDirectory(); @@ -236,9 +237,11 @@ public void GetRuntimeSpec_UsesAppHostSpecificTsConfig() private static int GetPort(string url) => new Uri(url).Port; + private const int WindowsEphemeralPortMin = 49152; + private static void AssertPortInRange(int port, int minInclusive, int maxExclusive) { Assert.InRange(port, minInclusive, maxExclusive - 1); - Assert.True(port < AppHostProfilePortGenerator.WindowsEphemeralPortMin, $"Expected port {port} to be below the Windows ephemeral range."); + Assert.True(port < WindowsEphemeralPortMin, $"Expected port {port} to be below the Windows ephemeral range."); } }