diff --git a/src/Aspire.Hosting/Dcp/DcpExecutor.cs b/src/Aspire.Hosting/Dcp/DcpExecutor.cs index e615694a67c..da852a53e30 100644 --- a/src/Aspire.Hosting/Dcp/DcpExecutor.cs +++ b/src/Aspire.Hosting/Dcp/DcpExecutor.cs @@ -837,7 +837,11 @@ private void EnsureProxylessEndpointPort(IResource resource, EndpointAnnotation private static bool NeedsPublicPort(IResource resource, EndpointAnnotation endpoint) { - return !endpoint.IsProxied && !TryGetEffectiveFixedPublicPort(resource, endpoint, randomizePorts: false, out _); + // DCP can allocate a port only for resources it launches as workloads. This includes compute + // resources and annotation-backed containers; integration-owned endpoints publish their own addresses. + return (resource is IComputeResource || resource.IsContainer()) && + !endpoint.IsProxied && + !TryGetEffectiveFixedPublicPort(resource, endpoint, randomizePorts: false, out _); } private int? TryGetPersistedProxylessEndpointPort(IResource resource, EndpointAnnotation endpoint) diff --git a/tests/Aspire.Hosting.DevTunnels.Tests/DevTunnelResourceBuilderExtensionsTests.cs b/tests/Aspire.Hosting.DevTunnels.Tests/DevTunnelResourceBuilderExtensionsTests.cs index 38ad81a459b..3628f5fd6a6 100644 --- a/tests/Aspire.Hosting.DevTunnels.Tests/DevTunnelResourceBuilderExtensionsTests.cs +++ b/tests/Aspire.Hosting.DevTunnels.Tests/DevTunnelResourceBuilderExtensionsTests.cs @@ -585,6 +585,61 @@ public async Task ShowTunnelUrlsCommand_UsesTargetEndpointNetworkContext() interaction.Message); } + [Fact] + public async Task DcpStartupPublishesDevTunnelUrls() + { + const int targetPort = 3000; + const string tunnelUrl = "https://n4skq32k-3000.use.devtunnels.ms"; + const string inspectUrl = "https://n4skq32k-3000-inspect.use.devtunnels.ms"; + using var cts = new CancellationTokenSource(TimeSpan.FromMinutes(2)); + var client = new TestDevTunnelClient + { + TunnelStatus = new("mytunnel", HostConnections: 1, ClientConnections: 0, Description: "", Labels: []) + { + Ports = + [ + new(targetPort, "http") + { + PortUri = new Uri($"{tunnelUrl}/") + } + ] + } + }; + var (command, arguments) = GetLongRunningCommand(); + using var builder = TestDistributedApplicationBuilder.Create(); + builder.Configuration["ASPIRE_DEVTUNNEL_CLI_PATH"] = command; + builder.Services.AddSingleton(client); + builder.Services.AddSingleton(); + + var target = builder.AddExecutable("target", command, Environment.CurrentDirectory, arguments) + .WithHttpEndpoint(targetPort: targetPort, name: "http"); + var tunnel = builder.AddDevTunnel("tunnel", "mytunnel") + .WithReference(target); + var tunnelPort = Assert.Single(tunnel.Resource.Ports); + foreach (var annotation in tunnel.Resource.Annotations.OfType().ToArray()) + { + tunnel.Resource.Annotations.Remove(annotation); + } + tunnel.WithArgs(arguments); + + using var app = builder.Build(); + + var startTask = app.StartAsync(cts.Token); + var resourceEvent = await app.ResourceNotifications.WaitForResourceAsync( + tunnelPort.Name, + e => e.Snapshot.State?.Text == KnownResourceStates.Running && + e.Snapshot.Urls.Any(u => u.Url == tunnelUrl && !u.IsInactive) && + e.Snapshot.Urls.Any(u => u.Url == inspectUrl && !u.IsInactive), + cts.Token); + await startTask; + + Assert.Equal("n4skq32k-3000.use.devtunnels.ms", tunnelPort.TunnelEndpointAnnotation.AllocatedEndpoint?.Address); + Assert.Contains(resourceEvent.Snapshot.Urls, u => u.Url == tunnelUrl && !u.IsInactive); + Assert.Contains(resourceEvent.Snapshot.Urls, u => u.Url == inspectUrl && !u.IsInactive); + + await app.StopAsync(cts.Token); + } + [Fact] public async Task ResourceReady_PublishesUrlProperties() { @@ -636,6 +691,15 @@ DevTunnelPortResource.InspectUrlPropertyName or }); } + private static (string Command, string[] Arguments) GetLongRunningCommand() + { + // Windows has no sleep executable, and `timeout` exits immediately when stdin is redirected. + // Loopback ping is dependency-free and remains bounded if test cleanup is interrupted. + return OperatingSystem.IsWindows() + ? ("cmd.exe", ["/c", "ping", "-n", "180", "127.0.0.1"]) + : ("sleep", ["180"]); + } + private sealed class ProjectA : IProjectMetadata { public string ProjectPath => "projectA"; diff --git a/tests/Aspire.Hosting.Dotnet.Tests/DotnetProjectResourceTests.cs b/tests/Aspire.Hosting.Dotnet.Tests/DotnetProjectResourceTests.cs index 57d2f3bc27b..9d45be48d45 100644 --- a/tests/Aspire.Hosting.Dotnet.Tests/DotnetProjectResourceTests.cs +++ b/tests/Aspire.Hosting.Dotnet.Tests/DotnetProjectResourceTests.cs @@ -84,7 +84,7 @@ public void AddDotnetProject_UsesDotnetCommandAndProjectDirectoryAsWorkingDirect } [Fact] - public void AddDotnetProject_ResourceSupportsServiceDiscovery() + public void AddDotnetProject_ResourceSupportsServiceDiscoveryAndIsComputeResource() { using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run); @@ -92,6 +92,7 @@ public void AddDotnetProject_ResourceSupportsServiceDiscovery() Assert.IsAssignableFrom(app.Resource); Assert.IsAssignableFrom(app.Resource); + Assert.IsAssignableFrom(app.Resource); } [Fact] diff --git a/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs b/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs index c28aab2617b..82de6123fc9 100644 --- a/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs +++ b/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs @@ -19,6 +19,7 @@ using Aspire.Dashboard.Model; using Aspire.Hosting.Dcp; using Aspire.Hosting.Dcp.Model; +using Aspire.Hosting.DevTunnels; using Aspire.Hosting.Diagnostics; using Aspire.Hosting.Publishing; using Aspire.Hosting.Tests.Utils; @@ -1051,6 +1052,88 @@ public async Task EndpointPortsExecutableNotReplicatedProxylessNoPortNoTargetPor Assert.Equal(allocatedPort, int.Parse(envVarVal, CultureInfo.InvariantCulture)); } + [Fact] + public async Task ProxylessPortAllocatorOnlyAllocatesPortsForDcpWorkloads() + { + var (rangeStart, rangeEnd) = GetAvailableConsecutivePortPair(); + var builder = DistributedApplication.CreateBuilder(); + + var compute = builder.AddExecutable("compute", "compute", Environment.CurrentDirectory) + .WithEndpoint(name: "tcp", isProxied: false); + var target = builder.AddExecutable("target", "target", Environment.CurrentDirectory) + .WithHttpEndpoint(targetPort: 8000, name: "http"); + builder.AddDevTunnel("tunnel") + .WithReference(target); + + var dcpOptions = new DcpOptions + { + DashboardPath = "./dashboard", + ProxylessEndpointPortRangeStart = rangeStart, + ProxylessEndpointPortRangeEnd = rangeEnd + }; + var kubernetesService = new TestKubernetesService(); + using var app = builder.Build(); + var distributedAppModel = app.Services.GetRequiredService(); + var tunnelPort = Assert.Single(distributedAppModel.Resources.OfType()); + var computeEndpoint = compute.GetEndpoint("tcp").EndpointAnnotation; + var tunnelEndpoint = Assert.Single(tunnelPort.Annotations.OfType()); + var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService, dcpOptions: dcpOptions); + + await appExecutor.RunApplicationAsync(); + + Assert.NotNull(computeEndpoint.AllocatedEndpoint); + var computePort = Assert.IsType(computeEndpoint.Port); + Assert.InRange(computePort, rangeStart, rangeEnd); + Assert.Equal(computePort, computeEndpoint.TargetPort); + Assert.Null(tunnelEndpoint.Port); + Assert.Null(tunnelEndpoint.TargetPort); + Assert.Null(tunnelEndpoint.AllocatedEndpoint); + } + + [Fact] + public async Task ProxylessPortAllocatorAllocatesPortForNonComputeContainerResource() + { + const int targetPort = 10000; + var (allocatedPort, _) = GetAvailableConsecutivePortPair(); + var builder = DistributedApplication.CreateBuilder(); + + var emulator = builder.AddResource(new TestContainerResource("emulator")) + .WithAnnotation(new ContainerImageAnnotation { Image = "image" }) + .WithAnnotation(new ContainerLifetimeAnnotation { Lifetime = ContainerLifetime.Persistent }) + .WithHttpEndpoint(targetPort: targetPort, name: "http"); + + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["AppHost:Sha256"] = "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" + }) + .Build(); + var dcpOptions = new DcpOptions + { + DashboardPath = "./dashboard", + ProxylessEndpointPortRangeStart = allocatedPort, + ProxylessEndpointPortRangeEnd = allocatedPort + }; + var kubernetesService = new TestKubernetesService(); + using var app = builder.Build(); + var distributedAppModel = app.Services.GetRequiredService(); + var endpoint = emulator.GetEndpoint("http").EndpointAnnotation; + var appExecutor = CreateAppExecutor( + distributedAppModel, + configuration: configuration, + kubernetesService: kubernetesService, + dcpOptions: dcpOptions); + + await appExecutor.RunApplicationAsync(); + + Assert.IsNotAssignableFrom(emulator.Resource); + Assert.True(emulator.Resource.IsContainer()); + Assert.Equal(allocatedPort, endpoint.Port); + Assert.Equal(targetPort, endpoint.TargetPort); + Assert.Equal(allocatedPort, endpoint.AllocatedEndpoint?.Port); + Assert.Single(kubernetesService.CreatedResources.OfType(), c => c.AppModelResourceName == emulator.Resource.Name); + } + [Fact] public async Task ProxylessPortAllocatorExcludesFixedPublicPorts() { @@ -10208,6 +10291,7 @@ private static X509Certificate2 CreateTestCertificate() private sealed class TestExecutableResource(string directory) : ExecutableResource("TestExecutable", "test", directory); private sealed class TestOtherExecutableResource(string directory) : ExecutableResource("TestOtherExecutable", "test-other", directory); + private sealed class TestContainerResource(string name) : Resource(name), IResourceWithEndpoints; private sealed class NullValueProvider : IValueProvider {