diff --git a/src/Aspire.Cli/Aspire.Cli.csproj b/src/Aspire.Cli/Aspire.Cli.csproj index aec002100aa..b29804475cb 100644 --- a/src/Aspire.Cli/Aspire.Cli.csproj +++ b/src/Aspire.Cli/Aspire.Cli.csproj @@ -93,6 +93,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..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,6 +24,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..f9516b5d734 --- /dev/null +++ b/src/Shared/AppHostProfilePortGenerator.cs @@ -0,0 +1,41 @@ +// 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 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..1b016e144bc 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,41 @@ public void Scaffold_DoesNotEmitRootTsConfig_WhenOneAlreadyExists() Assert.Equal(existingTsConfig, File.ReadAllText(existingTsConfigPath)); } + [Theory] + [InlineData(null)] + [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 +234,14 @@ 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 const int WindowsEphemeralPortMin = 49152; + + private static void AssertPortInRange(int port, int minInclusive, int maxExclusive) + { + Assert.InRange(port, minInclusive, maxExclusive - 1); + Assert.True(port < WindowsEphemeralPortMin, $"Expected port {port} to be below the Windows ephemeral range."); + } }