diff --git a/src/Aspire.Cli/Aspire.Cli.csproj b/src/Aspire.Cli/Aspire.Cli.csproj index 8d8ed84f6f3..efac95864cb 100644 --- a/src/Aspire.Cli/Aspire.Cli.csproj +++ b/src/Aspire.Cli/Aspire.Cli.csproj @@ -9,7 +9,7 @@ false aspire Aspire.Cli - $(NoWarn);CS1591 + $(NoWarn);CS1591;CS1574 true Size $(DefineConstants);CLI @@ -89,6 +89,9 @@ + + + diff --git a/src/Aspire.Cli/Program.cs b/src/Aspire.Cli/Program.cs index 052182ecd5f..ecd88b55108 100644 --- a/src/Aspire.Cli/Program.cs +++ b/src/Aspire.Cli/Program.cs @@ -289,6 +289,7 @@ internal static async Task BuildApplicationAsync(string[] args, Dictionar builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); + builder.Services.AddSingleton(); builder.Services.AddSingleton(); // MCP server transport factory - creates transport only when needed to avoid diff --git a/src/Aspire.Cli/Projects/GuestAppHostProject.cs b/src/Aspire.Cli/Projects/GuestAppHostProject.cs index 434b5977525..03162a8c7e6 100644 --- a/src/Aspire.Cli/Projects/GuestAppHostProject.cs +++ b/src/Aspire.Cli/Projects/GuestAppHostProject.cs @@ -3,7 +3,6 @@ using System.Diagnostics; using System.Net.Sockets; -using System.Text.Json; using Aspire.Cli.Backchannel; using Aspire.Cli.Certificates; using Aspire.Cli.Configuration; @@ -476,84 +475,15 @@ await GenerateCodeViaRpcAsync( private Dictionary? ReadLaunchSettingsEnvironmentVariables(DirectoryInfo directory) { - // For guest apphosts, look for apphost.run.json - // similar to how .NET single-file apphosts use apphost.run.json - var apphostRunPath = Path.Combine(directory.FullName, "apphost.run.json"); - var launchSettingsPath = Path.Combine(directory.FullName, "Properties", "launchSettings.json"); - - var configPath = File.Exists(apphostRunPath) ? apphostRunPath : launchSettingsPath; - - if (!File.Exists(configPath)) + var result = LaunchProfileHelper.ReadEnvironmentVariables(directory); + if (result is null) { _logger.LogDebug("No apphost.run.json or launchSettings.json found in {Path}", directory.FullName); return null; } - try - { - var json = File.ReadAllText(configPath); - using var doc = JsonDocument.Parse(json); - - if (!doc.RootElement.TryGetProperty("profiles", out var profiles)) - { - return null; - } - - // Try to find the 'https' profile first, then fall back to the first profile - JsonElement? profileElement = null; - if (profiles.TryGetProperty("https", out var httpsProfile)) - { - profileElement = httpsProfile; - } - else - { - // Use the first profile - using var enumerator = profiles.EnumerateObject(); - if (enumerator.MoveNext()) - { - profileElement = enumerator.Current.Value; - } - } - - if (profileElement == null) - { - return null; - } - - var result = new Dictionary(); - - // Read applicationUrl and convert to ASPNETCORE_URLS - if (profileElement.Value.TryGetProperty("applicationUrl", out var appUrl) && - appUrl.ValueKind == JsonValueKind.String) - { - result["ASPNETCORE_URLS"] = appUrl.GetString()!; - } - - // Read environment variables - if (profileElement.Value.TryGetProperty("environmentVariables", out var envVars)) - { - foreach (var prop in envVars.EnumerateObject()) - { - if (prop.Value.ValueKind == JsonValueKind.String) - { - result[prop.Name] = prop.Value.GetString()!; - } - } - } - - if (result.Count == 0) - { - return null; - } - - _logger.LogDebug("Read {Count} environment variables from apphost.run.json", result.Count); - return result; - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Failed to read launchSettings.json"); - return null; - } + _logger.LogDebug("Read {Count} environment variables from launch profile", result.Count); + return result; } /// diff --git a/src/Aspire.Cli/Utils/EnvironmentChecker/PortAvailabilityCheck.cs b/src/Aspire.Cli/Utils/EnvironmentChecker/PortAvailabilityCheck.cs new file mode 100644 index 00000000000..1eef9e28210 --- /dev/null +++ b/src/Aspire.Cli/Utils/EnvironmentChecker/PortAvailabilityCheck.cs @@ -0,0 +1,385 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics; +using System.Runtime.InteropServices; +using Microsoft.Extensions.Logging; + +namespace Aspire.Cli.Utils.EnvironmentChecker; + +/// +/// Checks if configured ports in apphost.run.json or launchSettings.json fall within +/// Windows excluded port ranges (e.g., Hyper-V dynamic reservations) or the OS ephemeral port range. +/// +internal sealed class PortAvailabilityCheck(CliExecutionContext executionContext, ILogger logger) : IEnvironmentCheck +{ + private const int ProcessTimeoutMs = 5000; + private const int ProcessExitGracePeriodMs = 500; + + public int Order => 45; // After container checks + + public Task> CheckAsync(CancellationToken cancellationToken = default) + { + var results = new List(); + + try + { + var ports = ReadConfiguredPorts(executionContext.WorkingDirectory); + if (ports.Count == 0) + { + return Task.FromResult>([]); + } + + // On Windows, check if any configured ports fall within OS-excluded port ranges + // (e.g., Hyper-V dynamic reservations that prevent any process from binding) + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + var excludedRanges = GetExcludedPortRanges(); + var excludedPorts = ports.Where(p => IsPortExcluded(p.Port, excludedRanges)).ToList(); + + if (excludedPorts.Count > 0) + { + var portList = string.Join(", ", excludedPorts.Select(p => $"{p.Port} ({p.Source})")); + results.Add(new EnvironmentCheckResult + { + Category = "environment", + Name = "port-availability", + Status = EnvironmentCheckStatus.Warning, + Message = $"Configured ports in excluded range: {portList}", + Details = "These ports fall within a Windows excluded port range (often reserved by Hyper-V). No process can bind to these ports, so the dashboard link displayed by 'aspire run' will not work.", + Fix = "Delete apphost.run.json (or launchSettings.json) and run 'aspire run' to auto-assign available ports, or update the file with ports outside excluded ranges.\nRun 'netsh interface ipv4 show excludedportrange protocol=tcp' to see reserved port ranges." + }); + } + } + + // Check if any configured ports fall in the ephemeral port range (high risk of random conflicts) + var ephemeralRange = GetEphemeralPortRange(); + if (ephemeralRange is not null) + { + var ephemeralPorts = ports + .Where(p => p.Port >= ephemeralRange.Value.Start && p.Port <= ephemeralRange.Value.End) + .ToList(); + + if (ephemeralPorts.Count > 0) + { + var portList = string.Join(", ", ephemeralPorts.Select(p => $"{p.Port} ({p.Source})")); + results.Add(new EnvironmentCheckResult + { + Category = "environment", + Name = "ephemeral-port-range", + Status = EnvironmentCheckStatus.Warning, + Message = $"Configured ports in ephemeral range: {portList}", + Details = $"These ports fall within the OS ephemeral port range ({ephemeralRange.Value.Start}-{ephemeralRange.Value.End}), which is used for outgoing connections and randomly assigned ports. This increases the chance of port conflicts.", + Fix = "Consider using ports outside the ephemeral range. Delete apphost.run.json (or launchSettings.json) and run 'aspire run' to auto-assign available ports, or update the file with ports below the ephemeral range (e.g., 15000-15100)." + }); + } + } + + if (results.Count == 0) + { + results.Add(new EnvironmentCheckResult + { + Category = "environment", + Name = "port-availability", + Status = EnvironmentCheckStatus.Pass, + Message = "No port conflicts detected" + }); + } + } + catch (Exception ex) + { + logger.LogDebug(ex, "Failed to check port availability"); + + results.Add(new EnvironmentCheckResult + { + Category = "environment", + Name = "port-availability", + Status = EnvironmentCheckStatus.Warning, + Message = "Unable to check port availability", + Details = ex.Message + }); + } + + return Task.FromResult>(results); + } + + /// + /// Reads configured ports from apphost.run.json or launchSettings.json using shared launch profile parsing. + /// + internal static List<(int Port, string Source)> ReadConfiguredPorts(DirectoryInfo workingDirectory) + { + var envVars = LaunchProfileHelper.ReadEnvironmentVariables(workingDirectory); + if (envVars is null) + { + return []; + } + + return ExtractPortsFromEnvironmentVariables(envVars); + } + + /// + /// Extracts port numbers from launch profile environment variables. + /// Any value that parses as an absolute URL with a port is included. + /// ASPNETCORE_URLS (mapped from applicationUrl) may contain semicolon-separated URLs. + /// + internal static List<(int Port, string Source)> ExtractPortsFromEnvironmentVariables(Dictionary envVars) + { + var ports = new List<(int Port, string Source)>(); + + foreach (var (key, value) in envVars) + { + if (key == "ASPNETCORE_URLS") + { + // applicationUrl is mapped to ASPNETCORE_URLS and may contain multiple semicolon-separated URLs + foreach (var url in value.Split(';', StringSplitOptions.RemoveEmptyEntries)) + { + if (Uri.TryCreate(url.Trim(), UriKind.Absolute, out var uri) && uri.Port > 0) + { + ports.Add((uri.Port, "applicationUrl")); + } + } + } + else if (Uri.TryCreate(value, UriKind.Absolute, out var uri) && uri.Port > 0 && uri.Scheme is "http" or "https") + { + ports.Add((uri.Port, key)); + } + } + + return ports; + } + + /// + /// Runs a command and returns its stdout, or null if it fails or times out. + /// + private static string? RunCommand(string fileName, string arguments) + { + try + { + using var process = new Process(); + process.StartInfo = new ProcessStartInfo + { + FileName = fileName, + Arguments = arguments, + RedirectStandardOutput = true, + UseShellExecute = false, + CreateNoWindow = true + }; + process.Start(); + + var outputTask = process.StandardOutput.ReadToEndAsync(); + if (!outputTask.Wait(ProcessTimeoutMs)) + { + try { process.Kill(); } catch { } + return null; + } + + process.WaitForExit(ProcessExitGracePeriodMs); + return outputTask.Result; + } + catch + { + return null; + } + } + + /// + /// Gets the Windows excluded port ranges using netsh. + /// + internal static List<(int Start, int End)> GetExcludedPortRanges() + { + var output = RunCommand("netsh", "interface ipv4 show excludedportrange protocol=tcp"); + return output is not null ? ParseExcludedPortRanges(output) : []; + } + + /// + /// Parses the output of 'netsh interface ipv4 show excludedportrange protocol=tcp'. + /// + /// + /// Expected format: + /// + /// Protocol tcp Port Exclusion Ranges + /// + /// Start Port End Port + /// ---------- -------- + /// 1080 1179 + /// 1180 1279 + /// 50000 50099 * + /// 56224 56323 + /// + /// * - Administered port exclusions. + /// + /// Lines with two leading integers are parsed as port ranges. + /// Header rows, dashes, and "* - Administered" lines are skipped automatically + /// because they don't start with two parseable integers. + /// + internal static List<(int Start, int End)> ParseExcludedPortRanges(string netshOutput) + { + var ranges = new List<(int Start, int End)>(); + + foreach (var line in netshOutput.Split('\n')) + { + var trimmed = line.Trim(); + var parts = trimmed.Split(' ', StringSplitOptions.RemoveEmptyEntries); + if (parts.Length >= 2 && + int.TryParse(parts[0], out var start) && + int.TryParse(parts[1], out var end)) + { + ranges.Add((start, end)); + } + } + + return ranges; + } + + /// + /// Gets the OS ephemeral (dynamic) port range. + /// On Windows: uses 'netsh int ipv4 show dynamicport tcp'. + /// On Linux: reads /proc/sys/net/ipv4/ip_local_port_range. + /// On macOS: uses sysctl net.inet.ip.portrange.first/last. + /// + internal static (int Start, int End)? GetEphemeralPortRange() + { + try + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + return GetWindowsEphemeralPortRange(); + } + else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + { + return GetLinuxEphemeralPortRange(); + } + else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + return GetMacOSEphemeralPortRange(); + } + } + catch + { + // Fall through to default + } + + // IANA default ephemeral range + return (49152, 65535); + } + + private static (int Start, int End)? GetWindowsEphemeralPortRange() + { + var output = RunCommand("netsh", "int ipv4 show dynamicport tcp"); + return output is not null ? ParseDynamicPortRange(output) : null; + } + + /// + /// Parses the output of 'netsh int ipv4 show dynamicport tcp'. + /// + /// + /// Expected format: + /// + /// Protocol tcp Dynamic Port Range + /// --------------------------------- + /// Start Port : 49152 + /// Number of Ports : 16384 + /// + /// The end port is calculated as Start Port + Number of Ports - 1 (e.g., 49152 + 16384 - 1 = 65535). + /// + internal static (int Start, int End)? ParseDynamicPortRange(string netshOutput) + { + int? startPort = null; + int? numberOfPorts = null; + + foreach (var line in netshOutput.Split('\n')) + { + var trimmed = line.Trim(); + if (trimmed.StartsWith("Start Port", StringComparison.OrdinalIgnoreCase)) + { + var colonIdx = trimmed.IndexOf(':'); + if (colonIdx >= 0 && int.TryParse(trimmed[(colonIdx + 1)..].Trim(), out var val)) + { + startPort = val; + } + } + else if (trimmed.StartsWith("Number of Ports", StringComparison.OrdinalIgnoreCase)) + { + var colonIdx = trimmed.IndexOf(':'); + if (colonIdx >= 0 && int.TryParse(trimmed[(colonIdx + 1)..].Trim(), out var val)) + { + numberOfPorts = val; + } + } + } + + if (startPort.HasValue && numberOfPorts.HasValue) + { + return (startPort.Value, startPort.Value + numberOfPorts.Value - 1); + } + + return null; + } + + /// + /// Reads the ephemeral port range on Linux from /proc/sys/net/ipv4/ip_local_port_range. + /// + /// + /// Expected format (tab-separated): + /// + /// 32768 60999 + /// + /// + private static (int Start, int End)? GetLinuxEphemeralPortRange() + { + const string path = "/proc/sys/net/ipv4/ip_local_port_range"; + if (!File.Exists(path)) + { + return null; + } + + var content = File.ReadAllText(path).Trim(); + var parts = content.Split('\t', ' '); + if (parts.Length >= 2 && + int.TryParse(parts[0].Trim(), out var start) && + int.TryParse(parts[^1].Trim(), out var end)) + { + return (start, end); + } + + return null; + } + + /// + /// Reads the ephemeral port range on macOS via sysctl. + /// + /// + /// Each sysctl key returns a single integer: + /// + /// $ sysctl -n net.inet.ip.portrange.first + /// 49152 + /// $ sysctl -n net.inet.ip.portrange.last + /// 65535 + /// + /// + private static (int Start, int End)? GetMacOSEphemeralPortRange() + { + static int? ReadSysctl(string key) + { + var output = RunCommand("sysctl", $"-n {key}"); + return output is not null && int.TryParse(output.Trim(), out var val) ? val : null; + } + + var first = ReadSysctl("net.inet.ip.portrange.first"); + var last = ReadSysctl("net.inet.ip.portrange.last"); + + return (first.HasValue && last.HasValue) ? (first.Value, last.Value) : null; + } + + private static bool IsPortExcluded(int port, List<(int Start, int End)> excludedRanges) + { + foreach (var (start, end) in excludedRanges) + { + if (port >= start && port <= end) + { + return true; + } + } + return false; + } +} diff --git a/src/Aspire.Cli/Utils/LaunchProfileHelper.cs b/src/Aspire.Cli/Utils/LaunchProfileHelper.cs new file mode 100644 index 00000000000..c2bfe90f043 --- /dev/null +++ b/src/Aspire.Cli/Utils/LaunchProfileHelper.cs @@ -0,0 +1,107 @@ +// 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; +using Aspire.Hosting; + +namespace Aspire.Cli.Utils; + +/// +/// Shared helper for reading apphost.run.json or launchSettings.json launch profiles. +/// +internal static class LaunchProfileHelper +{ + /// + /// Reads environment variables from apphost.run.json or launchSettings.json in the given directory. + /// Prefers apphost.run.json, falls back to Properties/launchSettings.json. + /// Selects the "https" profile first, then falls back to the first profile. + /// The applicationUrl property is mapped to ASPNETCORE_URLS. + /// + /// A dictionary of environment variables, or null if no config file was found or it couldn't be parsed. + public static Dictionary? ReadEnvironmentVariables(DirectoryInfo directory) + { + var configPath = ResolveConfigPath(directory); + if (configPath is null) + { + return null; + } + + return ReadEnvironmentVariables(configPath); + } + + /// + /// Reads environment variables from the given launch profile JSON file. + /// + public static Dictionary? ReadEnvironmentVariables(string configPath) + { + try + { + using var stream = File.OpenRead(configPath); + var settings = JsonSerializer.Deserialize(stream, LaunchSettingsSerializerContext.Default.LaunchSettings); + + if (settings is null || settings.Profiles.Count == 0) + { + return null; + } + + var profile = SelectProfile(settings); + if (profile is null) + { + return null; + } + + var result = new Dictionary(); + + // Read applicationUrl and convert to ASPNETCORE_URLS + if (!string.IsNullOrEmpty(profile.ApplicationUrl)) + { + result["ASPNETCORE_URLS"] = profile.ApplicationUrl; + } + + // Read environment variables + foreach (var (key, value) in profile.EnvironmentVariables) + { + result[key] = value; + } + + return result.Count == 0 ? null : result; + } + catch + { + return null; + } + } + + /// + /// Resolves the config file path, preferring apphost.run.json over Properties/launchSettings.json. + /// + /// The path to the config file, or null if neither exists. + public static string? ResolveConfigPath(DirectoryInfo directory) + { + var apphostRunPath = Path.Combine(directory.FullName, "apphost.run.json"); + if (File.Exists(apphostRunPath)) + { + return apphostRunPath; + } + + var launchSettingsPath = Path.Combine(directory.FullName, "Properties", "launchSettings.json"); + if (File.Exists(launchSettingsPath)) + { + return launchSettingsPath; + } + + return null; + } + + private static LaunchProfile? SelectProfile(LaunchSettings settings) + { + // Try to find the 'https' profile first, then fall back to the first profile + if (settings.Profiles.TryGetValue("https", out var httpsProfile)) + { + return httpsProfile; + } + + using var enumerator = settings.Profiles.GetEnumerator(); + return enumerator.MoveNext() ? enumerator.Current.Value : null; + } +} diff --git a/tests/Aspire.Cli.Tests/Utils/PortAvailabilityCheckTests.cs b/tests/Aspire.Cli.Tests/Utils/PortAvailabilityCheckTests.cs new file mode 100644 index 00000000000..abcd4c1bb7a --- /dev/null +++ b/tests/Aspire.Cli.Tests/Utils/PortAvailabilityCheckTests.cs @@ -0,0 +1,298 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Cli.Utils.EnvironmentChecker; + +namespace Aspire.Cli.Tests.Utils; + +public class PortAvailabilityCheckTests +{ + [Fact] + public void ReadConfiguredPorts_WithValidApphostRunJson_ReturnsPorts() + { + // Arrange + var tempDir = Directory.CreateTempSubdirectory("aspire-port-test-"); + try + { + var json = """ + { + "profiles": { + "https": { + "applicationUrl": "https://localhost:15000;http://localhost:15001", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:15002", + "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:15003" + } + } + } + } + """; + File.WriteAllText(Path.Combine(tempDir.FullName, "apphost.run.json"), json); + + // Act + var ports = PortAvailabilityCheck.ReadConfiguredPorts(tempDir); + + // Assert + Assert.Contains(ports, p => p.Port == 15000 && p.Source == "applicationUrl"); + Assert.Contains(ports, p => p.Port == 15001 && p.Source == "applicationUrl"); + Assert.Contains(ports, p => p.Port == 15002 && p.Source == "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL"); + Assert.Contains(ports, p => p.Port == 15003 && p.Source == "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL"); + Assert.Equal(4, ports.Count); + } + finally + { + tempDir.Delete(true); + } + } + + [Fact] + public void ReadConfiguredPorts_WithNoConfigFile_ReturnsEmpty() + { + // Arrange + var tempDir = Directory.CreateTempSubdirectory("aspire-port-test-"); + try + { + // Act + var ports = PortAvailabilityCheck.ReadConfiguredPorts(tempDir); + + // Assert + Assert.Empty(ports); + } + finally + { + tempDir.Delete(true); + } + } + + [Fact] + public void ReadConfiguredPorts_WithLaunchSettings_ReturnsPorts() + { + // Arrange + var tempDir = Directory.CreateTempSubdirectory("aspire-port-test-"); + try + { + var propsDir = Directory.CreateDirectory(Path.Combine(tempDir.FullName, "Properties")); + var json = """ + { + "profiles": { + "https": { + "applicationUrl": "https://localhost:5001;http://localhost:5000" + } + } + } + """; + File.WriteAllText(Path.Combine(propsDir.FullName, "launchSettings.json"), json); + + // Act + var ports = PortAvailabilityCheck.ReadConfiguredPorts(tempDir); + + // Assert + Assert.Contains(ports, p => p.Port == 5001); + Assert.Contains(ports, p => p.Port == 5000); + } + finally + { + tempDir.Delete(true); + } + } + + [Fact] + public void ReadConfiguredPorts_PrefersApphostRunJson_OverLaunchSettings() + { + // Arrange + var tempDir = Directory.CreateTempSubdirectory("aspire-port-test-"); + try + { + // Create both files with different ports + File.WriteAllText(Path.Combine(tempDir.FullName, "apphost.run.json"), """ + { + "profiles": { + "https": { + "applicationUrl": "https://localhost:9999" + } + } + } + """); + + var propsDir = Directory.CreateDirectory(Path.Combine(tempDir.FullName, "Properties")); + File.WriteAllText(Path.Combine(propsDir.FullName, "launchSettings.json"), """ + { + "profiles": { + "https": { + "applicationUrl": "https://localhost:8888" + } + } + } + """); + + // Act + var ports = PortAvailabilityCheck.ReadConfiguredPorts(tempDir); + + // Assert - should use apphost.run.json + Assert.Contains(ports, p => p.Port == 9999); + Assert.DoesNotContain(ports, p => p.Port == 8888); + } + finally + { + tempDir.Delete(true); + } + } + + [Fact] + public void ReadConfiguredPorts_FallsBackToFirstProfile_WhenNoHttpsProfile() + { + // Arrange + var tempDir = Directory.CreateTempSubdirectory("aspire-port-test-"); + try + { + File.WriteAllText(Path.Combine(tempDir.FullName, "apphost.run.json"), """ + { + "profiles": { + "default": { + "applicationUrl": "http://localhost:7777" + } + } + } + """); + + // Act + var ports = PortAvailabilityCheck.ReadConfiguredPorts(tempDir); + + // Assert + Assert.Contains(ports, p => p.Port == 7777); + } + finally + { + tempDir.Delete(true); + } + } + + [Fact] + public void ParseExcludedPortRanges_WithTypicalNetshOutput_ParsesRanges() + { + var output = """ + + Protocol tcp Port Exclusion Ranges + + Start Port End Port + ---------- -------- + 1080 1179 + 1180 1279 + 50000 50099 * + 56224 56323 + + * - Administered port exclusions. + + """; + + var ranges = PortAvailabilityCheck.ParseExcludedPortRanges(output); + + Assert.Equal(4, ranges.Count); + Assert.Contains(ranges, r => r.Start == 1080 && r.End == 1179); + Assert.Contains(ranges, r => r.Start == 1180 && r.End == 1279); + Assert.Contains(ranges, r => r.Start == 50000 && r.End == 50099); + Assert.Contains(ranges, r => r.Start == 56224 && r.End == 56323); + } + + [Fact] + public void ParseExcludedPortRanges_WithEmptyOutput_ReturnsEmpty() + { + var ranges = PortAvailabilityCheck.ParseExcludedPortRanges(""); + Assert.Empty(ranges); + } + + [Fact] + public void ParseExcludedPortRanges_WithHeadersOnly_ReturnsEmpty() + { + var output = """ + + Protocol tcp Port Exclusion Ranges + + Start Port End Port + ---------- -------- + + * - Administered port exclusions. + + """; + + var ranges = PortAvailabilityCheck.ParseExcludedPortRanges(output); + Assert.Empty(ranges); + } + + [Fact] + public void ExtractPortsFromEnvironmentVariables_ExtractsUrlsAndEndpoints() + { + var envVars = new Dictionary + { + ["ASPNETCORE_URLS"] = "https://localhost:15000;http://localhost:15001", + ["ASPNETCORE_ENVIRONMENT"] = "Development", + ["ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL"] = "https://localhost:15002", + ["ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL"] = "https://localhost:15003" + }; + + var ports = PortAvailabilityCheck.ExtractPortsFromEnvironmentVariables(envVars); + + Assert.Equal(4, ports.Count); + Assert.Contains(ports, p => p.Port == 15000 && p.Source == "applicationUrl"); + Assert.Contains(ports, p => p.Port == 15001 && p.Source == "applicationUrl"); + Assert.Contains(ports, p => p.Port == 15002 && p.Source == "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL"); + Assert.Contains(ports, p => p.Port == 15003 && p.Source == "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL"); + } + + [Fact] + public void ExtractPortsFromEnvironmentVariables_IgnoresNonUrlVars() + { + var envVars = new Dictionary + { + ["ASPNETCORE_ENVIRONMENT"] = "Development", + ["DOTNET_ENVIRONMENT"] = "Development" + }; + + var ports = PortAvailabilityCheck.ExtractPortsFromEnvironmentVariables(envVars); + Assert.Empty(ports); + } + + [Fact] + public void ParseDynamicPortRange_WithTypicalOutput_ParsesRange() + { + var output = """ + + Protocol tcp Dynamic Port Range + --------------------------------- + Start Port : 49152 + Number of Ports : 16384 + + """; + + var range = PortAvailabilityCheck.ParseDynamicPortRange(output); + + Assert.NotNull(range); + Assert.Equal(49152, range.Value.Start); + Assert.Equal(65535, range.Value.End); + } + + [Fact] + public void ParseDynamicPortRange_WithEmptyOutput_ReturnsNull() + { + var range = PortAvailabilityCheck.ParseDynamicPortRange(""); + Assert.Null(range); + } + + [Fact] + public void ParseDynamicPortRange_WithCustomRange_ParsesCorrectly() + { + var output = """ + Protocol tcp Dynamic Port Range + --------------------------------- + Start Port : 1024 + Number of Ports : 64511 + """; + + var range = PortAvailabilityCheck.ParseDynamicPortRange(output); + + Assert.NotNull(range); + Assert.Equal(1024, range.Value.Start); + Assert.Equal(65534, range.Value.End); + } +}