From ee584c7e1aabeac97d3de5d4b0dd9267ce3387b6 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Tue, 8 Oct 2024 18:55:04 +1100 Subject: [PATCH 1/6] Initial integration of codespace URL rewriting logic into hosting. --- .../Properties/launchSettings.json | 5 +- .../Codespaces/CodespacesUrlRewriter.cs | 62 +++++++++++++++++++ .../DistributedApplicationBuilder.cs | 13 ++++ 3 files changed, 79 insertions(+), 1 deletion(-) create mode 100644 src/Aspire.Hosting/Codespaces/CodespacesUrlRewriter.cs diff --git a/playground/waitfor/WaitForSandbox.AppHost/Properties/launchSettings.json b/playground/waitfor/WaitForSandbox.AppHost/Properties/launchSettings.json index f9dac51d48d..1d1bdd9111c 100644 --- a/playground/waitfor/WaitForSandbox.AppHost/Properties/launchSettings.json +++ b/playground/waitfor/WaitForSandbox.AppHost/Properties/launchSettings.json @@ -11,7 +11,10 @@ "DOTNET_ENVIRONMENT": "Development", "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:16175", "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:17037", - "DOTNET_ASPIRE_SHOW_DASHBOARD_RESOURCES": "true" + "DOTNET_ASPIRE_SHOW_DASHBOARD_RESOURCES": "true", + "CODESPACES": "true", + "GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN": "app.github.local", + "CODESPACE_NAME": "bob" } }, "http": { diff --git a/src/Aspire.Hosting/Codespaces/CodespacesUrlRewriter.cs b/src/Aspire.Hosting/Codespaces/CodespacesUrlRewriter.cs new file mode 100644 index 00000000000..912cfd0e9e1 --- /dev/null +++ b/src/Aspire.Hosting/Codespaces/CodespacesUrlRewriter.cs @@ -0,0 +1,62 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Immutable; +using Aspire.Hosting.ApplicationModel; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Hosting; + +namespace Aspire.Hosting.Codespaces; + +internal class CodespacesUrlRewriter(IConfiguration configuration, ResourceNotificationService resourceNotificationService) : BackgroundService +{ + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + var gitHubCodespacesPortForwardingDomain = configuration.GetValue("GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN") ?? throw new DistributedApplicationException("Codespaces was detected but GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN environment missing."); + var codespaceName = configuration.GetValue("CODESPACE_NAME") ?? throw new DistributedApplicationException("Codespaces was detected but CODESPACE_NAME environment missing."); + + do + { + var resourceEvents = resourceNotificationService.WatchAsync(stoppingToken); + + await foreach (var resourceEvent in resourceEvents.ConfigureAwait(false)) + { + Dictionary? remappedUrls = null; + + foreach (var originalUrlSnapshot in resourceEvent.Snapshot.Urls) + { + var uri = new Uri(originalUrlSnapshot.Url); + + if (!originalUrlSnapshot.IsInternal && (uri.Scheme == "http" || uri.Scheme == "https") && uri.Host == "localhost") + { + if (remappedUrls is null) + { + remappedUrls = new(); + } + + var newUrlSnapshot = originalUrlSnapshot with + { + Url = $"{uri.Scheme}://{codespaceName}-{uri.Port}.{gitHubCodespacesPortForwardingDomain}{uri.AbsolutePath}" + }; + + remappedUrls.Add(originalUrlSnapshot, newUrlSnapshot); + } + } + + if (remappedUrls is not null) + { + var transformedUrls = from originalUrl in resourceEvent.Snapshot.Urls + select remappedUrls.TryGetValue(originalUrl, out var remappedUrl) ? remappedUrl : originalUrl; + + await resourceNotificationService.PublishUpdateAsync(resourceEvent.Resource, resourceEvent.ResourceId, s => s with + { + Urls = transformedUrls.ToImmutableArray() + }).ConfigureAwait(false); + } + } + + // Short delay if we crash just to avoid spinning CPU. + await Task.Delay(5000, stoppingToken).ConfigureAwait(false); + } while (!stoppingToken.IsCancellationRequested); + } +} diff --git a/src/Aspire.Hosting/DistributedApplicationBuilder.cs b/src/Aspire.Hosting/DistributedApplicationBuilder.cs index 09eb05c88f3..8b2ef9b43c8 100644 --- a/src/Aspire.Hosting/DistributedApplicationBuilder.cs +++ b/src/Aspire.Hosting/DistributedApplicationBuilder.cs @@ -6,6 +6,7 @@ using System.Security.Cryptography; using System.Text; using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.Codespaces; using Aspire.Hosting.Dashboard; using Aspire.Hosting.Dcp; using Aspire.Hosting.Eventing; @@ -189,6 +190,7 @@ public DistributedApplicationBuilder(DistributedApplicationOptions options) _innerBuilder.Services.AddSingleton(Eventing); _innerBuilder.Services.AddHealthChecks(); + ConfigureCodespacesUrlRewriter(); ConfigureHealthChecks(); if (ExecutionContext.IsRunMode) @@ -279,6 +281,17 @@ public DistributedApplicationBuilder(DistributedApplicationOptions options) LogBuilderConstructed(this); } + private void ConfigureCodespacesUrlRewriter() + { + var isRunningInCodepaces = _innerBuilder.Configuration.GetBool("CODESPACES", false); + if (!ExecutionContext.IsRunMode || !isRunningInCodepaces) + { + return; + } + + _innerBuilder.Services.AddHostedService(); + } + private void ConfigureHealthChecks() { _innerBuilder.Services.AddSingleton>(sp => From 8a8b8795dc06ef9120f9eddf7ee01ddeb1b9a0a1 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Tue, 8 Oct 2024 22:35:34 +1100 Subject: [PATCH 2/6] Add unit tests. --- .../Properties/launchSettings.json | 5 +- .../Codespaces/CodespacesUrlRewriter.cs | 9 +- .../DistributedApplicationBuilder.cs | 15 +-- .../Codespaces/CodespacesUrlRewriterTests.cs | 103 ++++++++++++++++++ 4 files changed, 115 insertions(+), 17 deletions(-) create mode 100644 tests/Aspire.Hosting.Tests/Codespaces/CodespacesUrlRewriterTests.cs diff --git a/playground/waitfor/WaitForSandbox.AppHost/Properties/launchSettings.json b/playground/waitfor/WaitForSandbox.AppHost/Properties/launchSettings.json index 1d1bdd9111c..f9dac51d48d 100644 --- a/playground/waitfor/WaitForSandbox.AppHost/Properties/launchSettings.json +++ b/playground/waitfor/WaitForSandbox.AppHost/Properties/launchSettings.json @@ -11,10 +11,7 @@ "DOTNET_ENVIRONMENT": "Development", "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:16175", "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:17037", - "DOTNET_ASPIRE_SHOW_DASHBOARD_RESOURCES": "true", - "CODESPACES": "true", - "GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN": "app.github.local", - "CODESPACE_NAME": "bob" + "DOTNET_ASPIRE_SHOW_DASHBOARD_RESOURCES": "true" } }, "http": { diff --git a/src/Aspire.Hosting/Codespaces/CodespacesUrlRewriter.cs b/src/Aspire.Hosting/Codespaces/CodespacesUrlRewriter.cs index 912cfd0e9e1..524d36be052 100644 --- a/src/Aspire.Hosting/Codespaces/CodespacesUrlRewriter.cs +++ b/src/Aspire.Hosting/Codespaces/CodespacesUrlRewriter.cs @@ -5,13 +5,20 @@ using Aspire.Hosting.ApplicationModel; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; namespace Aspire.Hosting.Codespaces; -internal class CodespacesUrlRewriter(IConfiguration configuration, ResourceNotificationService resourceNotificationService) : BackgroundService +internal class CodespacesUrlRewriter(ILogger logger, IConfiguration configuration, ResourceNotificationService resourceNotificationService) : BackgroundService { protected override async Task ExecuteAsync(CancellationToken stoppingToken) { + if (!configuration.GetValue("CODESPACES", false)) + { + logger.LogTrace("Not running in Codespaces, skipping URL rewriting."); + return; + } + var gitHubCodespacesPortForwardingDomain = configuration.GetValue("GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN") ?? throw new DistributedApplicationException("Codespaces was detected but GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN environment missing."); var codespaceName = configuration.GetValue("CODESPACE_NAME") ?? throw new DistributedApplicationException("Codespaces was detected but CODESPACE_NAME environment missing."); diff --git a/src/Aspire.Hosting/DistributedApplicationBuilder.cs b/src/Aspire.Hosting/DistributedApplicationBuilder.cs index 8b2ef9b43c8..0c5f637213f 100644 --- a/src/Aspire.Hosting/DistributedApplicationBuilder.cs +++ b/src/Aspire.Hosting/DistributedApplicationBuilder.cs @@ -190,7 +190,6 @@ public DistributedApplicationBuilder(DistributedApplicationOptions options) _innerBuilder.Services.AddSingleton(Eventing); _innerBuilder.Services.AddHealthChecks(); - ConfigureCodespacesUrlRewriter(); ConfigureHealthChecks(); if (ExecutionContext.IsRunMode) @@ -262,6 +261,9 @@ public DistributedApplicationBuilder(DistributedApplicationOptions options) _innerBuilder.Services.AddSingleton(new Locations()); _innerBuilder.Services.AddSingleton(); + // Codespaces + _innerBuilder.Services.AddHostedService(); + Eventing.Subscribe(BuiltInDistributedApplicationEventSubscriptionHandlers.InitializeDcpAnnotations); } @@ -281,17 +283,6 @@ public DistributedApplicationBuilder(DistributedApplicationOptions options) LogBuilderConstructed(this); } - private void ConfigureCodespacesUrlRewriter() - { - var isRunningInCodepaces = _innerBuilder.Configuration.GetBool("CODESPACES", false); - if (!ExecutionContext.IsRunMode || !isRunningInCodepaces) - { - return; - } - - _innerBuilder.Services.AddHostedService(); - } - private void ConfigureHealthChecks() { _innerBuilder.Services.AddSingleton>(sp => diff --git a/tests/Aspire.Hosting.Tests/Codespaces/CodespacesUrlRewriterTests.cs b/tests/Aspire.Hosting.Tests/Codespaces/CodespacesUrlRewriterTests.cs new file mode 100644 index 00000000000..b004b8bdf2b --- /dev/null +++ b/tests/Aspire.Hosting.Tests/Codespaces/CodespacesUrlRewriterTests.cs @@ -0,0 +1,103 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Hosting.Utils; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Xunit; +using Xunit.Abstractions; + +namespace Aspire.Hosting.Tests.Codespaces; + +public class CodespacesUrlRewriterTests(ITestOutputHelper testOutputHelper) +{ + [Fact] + public async Task VerifyUrlsRewriterStopsWhenNotInCodespaces() + { + using var builder = TestDistributedApplicationBuilder.Create(testOutputHelper); + builder.Services.AddLogging(logging => + { + logging.AddFakeLogging(); + logging.AddXunit(testOutputHelper); + }); + + var resource = builder.AddResource(new CustomResource("resource")); + + var abortToken = new CancellationTokenSource(TimeSpan.FromSeconds(60)); + + using var app = builder.Build(); + var rns = app.Services.GetRequiredService(); + + await app.StartAsync(abortToken.Token); + + var collector = app.Services.GetFakeLogCollector(); + + var urlRewriterStopped = false; + + while (!abortToken.Token.IsCancellationRequested) + { + var logs = collector.GetSnapshot(); + urlRewriterStopped = logs.Any(l => l.Message.Contains("Not running in Codespaces, skipping URL rewriting.")); + if (urlRewriterStopped) + { + break; + } + } + + Assert.True(urlRewriterStopped); + + await app.StopAsync(abortToken.Token); + } + + [Fact] + public async Task VerifyUrlsRewrittenWhenInCodespaces() + { + using var builder = TestDistributedApplicationBuilder.Create(testOutputHelper); + + builder.Configuration["CODESPACES"] = "true"; + builder.Configuration["GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN"] = "app.github.dev"; + builder.Configuration["CODESPACE_NAME"] = "test-codespace"; + + var resource = builder.AddResource(new CustomResource("resource")); + + var abortToken = new CancellationTokenSource(TimeSpan.FromSeconds(60)); + + using var app = builder.Build(); + var rns = app.Services.GetRequiredService(); + + await app.StartAsync(abortToken.Token); + + // Push the URL to the resource state. + var localhostUrlSnapshot = new UrlSnapshot("Test", "http://localhost:1234", false); + await rns.PublishUpdateAsync(resource.Resource, s => s with + { + State = KnownResourceStates.Running, + Urls = [localhostUrlSnapshot] + }); + + // Wait until + var resourceEvent = await rns.WaitForResourceAsync( + resource.Resource.Name, + (re) => { + var match = re.Snapshot.Urls.Length > 0 && re.Snapshot.Urls[0].Url.Contains("app.github.dev"); + return match; + }, + abortToken.Token); + + Assert.Collection( + resourceEvent.Snapshot.Urls, + u => + { + Assert.Equal("Test", u.Name); + Assert.Equal("http://test-codespace-1234.app.github.dev/", u.Url); + Assert.False(u.IsInternal); + } + ); + + await app.StopAsync(abortToken.Token); + } + + private sealed class CustomResource(string name) : Resource(name) + { + } +} From 5901dd886f940c81fa218c6910585c8dc8127f30 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Wed, 9 Oct 2024 08:33:35 +1100 Subject: [PATCH 3/6] Update src/Aspire.Hosting/Codespaces/CodespacesUrlRewriter.cs Co-authored-by: James Newton-King --- src/Aspire.Hosting/Codespaces/CodespacesUrlRewriter.cs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/Aspire.Hosting/Codespaces/CodespacesUrlRewriter.cs b/src/Aspire.Hosting/Codespaces/CodespacesUrlRewriter.cs index 524d36be052..69c0a72dc33 100644 --- a/src/Aspire.Hosting/Codespaces/CodespacesUrlRewriter.cs +++ b/src/Aspire.Hosting/Codespaces/CodespacesUrlRewriter.cs @@ -36,10 +36,7 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) if (!originalUrlSnapshot.IsInternal && (uri.Scheme == "http" || uri.Scheme == "https") && uri.Host == "localhost") { - if (remappedUrls is null) - { - remappedUrls = new(); - } + remappedUrls ??= new(); var newUrlSnapshot = originalUrlSnapshot with { From f580e604aac30bc0bca1e6175c87a436e890d2ed Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Wed, 9 Oct 2024 08:55:32 +1100 Subject: [PATCH 4/6] PR feedback. --- .../Codespaces/CodespacesUrlRewriter.cs | 61 +++++++++++-------- 1 file changed, 37 insertions(+), 24 deletions(-) diff --git a/src/Aspire.Hosting/Codespaces/CodespacesUrlRewriter.cs b/src/Aspire.Hosting/Codespaces/CodespacesUrlRewriter.cs index 69c0a72dc33..178e9c78f9e 100644 --- a/src/Aspire.Hosting/Codespaces/CodespacesUrlRewriter.cs +++ b/src/Aspire.Hosting/Codespaces/CodespacesUrlRewriter.cs @@ -24,43 +24,56 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) do { - var resourceEvents = resourceNotificationService.WatchAsync(stoppingToken); - - await foreach (var resourceEvent in resourceEvents.ConfigureAwait(false)) + try { - Dictionary? remappedUrls = null; + var resourceEvents = resourceNotificationService.WatchAsync(stoppingToken); - foreach (var originalUrlSnapshot in resourceEvent.Snapshot.Urls) + await foreach (var resourceEvent in resourceEvents.ConfigureAwait(false)) { - var uri = new Uri(originalUrlSnapshot.Url); + Dictionary? remappedUrls = null; - if (!originalUrlSnapshot.IsInternal && (uri.Scheme == "http" || uri.Scheme == "https") && uri.Host == "localhost") + foreach (var originalUrlSnapshot in resourceEvent.Snapshot.Urls) { - remappedUrls ??= new(); + var uri = new Uri(originalUrlSnapshot.Url); - var newUrlSnapshot = originalUrlSnapshot with + if (!originalUrlSnapshot.IsInternal && (uri.Scheme == "http" || uri.Scheme == "https") && uri.Host == "localhost") { - Url = $"{uri.Scheme}://{codespaceName}-{uri.Port}.{gitHubCodespacesPortForwardingDomain}{uri.AbsolutePath}" - }; + remappedUrls ??= new(); - remappedUrls.Add(originalUrlSnapshot, newUrlSnapshot); - } - } + var newUrlSnapshot = originalUrlSnapshot with + { + // The format of GitHub Codespaces URLs comprises the codespace + // name (from the CODESPACE_NAME environment variable, the port, + // and the port forwarding domain (via GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN + // which is typically ".app.github.dev". The VSCode instance is typically + // hosted at codespacename.github.dev whereas the forwarded ports + // would be at codespacename-port.app.github.dev. + Url = $"{uri.Scheme}://{codespaceName}-{uri.Port}.{gitHubCodespacesPortForwardingDomain}{uri.AbsolutePath}" + }; - if (remappedUrls is not null) - { - var transformedUrls = from originalUrl in resourceEvent.Snapshot.Urls - select remappedUrls.TryGetValue(originalUrl, out var remappedUrl) ? remappedUrl : originalUrl; + remappedUrls.Add(originalUrlSnapshot, newUrlSnapshot); + } + } - await resourceNotificationService.PublishUpdateAsync(resourceEvent.Resource, resourceEvent.ResourceId, s => s with + if (remappedUrls is not null) { - Urls = transformedUrls.ToImmutableArray() - }).ConfigureAwait(false); + var transformedUrls = from originalUrl in resourceEvent.Snapshot.Urls + select remappedUrls.TryGetValue(originalUrl, out var remappedUrl) ? remappedUrl : originalUrl; + + await resourceNotificationService.PublishUpdateAsync(resourceEvent.Resource, resourceEvent.ResourceId, s => s with + { + Urls = transformedUrls.ToImmutableArray() + }).ConfigureAwait(false); + } } } - - // Short delay if we crash just to avoid spinning CPU. - await Task.Delay(5000, stoppingToken).ConfigureAwait(false); + catch (Exception ex) when (!stoppingToken.IsCancellationRequested) + { + // When debugging sometimes we'll get cancelled here but we don't want + // to tear down the loop. We only want to crash out when the service's + // cancellation token is signaled. + logger.LogTrace(ex, "Codespace URL rewriting loop threw an exception but was ignored."); + } } while (!stoppingToken.IsCancellationRequested); } } From 54fee06075e7ee8728761ce58365b473a0b45625 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Wed, 9 Oct 2024 09:12:44 +1100 Subject: [PATCH 5/6] Share config acquisition logic/exceptions. --- .../Codespaces/CodespacesUrlRewriter.cs | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/src/Aspire.Hosting/Codespaces/CodespacesUrlRewriter.cs b/src/Aspire.Hosting/Codespaces/CodespacesUrlRewriter.cs index 178e9c78f9e..63587167608 100644 --- a/src/Aspire.Hosting/Codespaces/CodespacesUrlRewriter.cs +++ b/src/Aspire.Hosting/Codespaces/CodespacesUrlRewriter.cs @@ -11,16 +11,26 @@ namespace Aspire.Hosting.Codespaces; internal class CodespacesUrlRewriter(ILogger logger, IConfiguration configuration, ResourceNotificationService resourceNotificationService) : BackgroundService { + private const string CodespacesEnvironmentVariable = "CODESPACES"; + private const string CodespaceNameEnvironmentVariable = "CODESPACE_NAME"; + private const string GitHubCodespacesPortForwardingDomain = "GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN"; + + private string GetRequiredCodespacesConfigurationValue(string key) + { + ArgumentNullException.ThrowIfNullOrEmpty(key); + return configuration.GetValue(key) ?? throw new DistributedApplicationException($"Codespaces was detected but {key} environment missing."); + } + protected override async Task ExecuteAsync(CancellationToken stoppingToken) { - if (!configuration.GetValue("CODESPACES", false)) + if (!configuration.GetValue(CodespacesEnvironmentVariable, false)) { logger.LogTrace("Not running in Codespaces, skipping URL rewriting."); return; } - var gitHubCodespacesPortForwardingDomain = configuration.GetValue("GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN") ?? throw new DistributedApplicationException("Codespaces was detected but GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN environment missing."); - var codespaceName = configuration.GetValue("CODESPACE_NAME") ?? throw new DistributedApplicationException("Codespaces was detected but CODESPACE_NAME environment missing."); + var gitHubCodespacesPortForwardingDomain = GetRequiredCodespacesConfigurationValue(GitHubCodespacesPortForwardingDomain); + var codespaceName = GetRequiredCodespacesConfigurationValue(CodespaceNameEnvironmentVariable); do { From 5f406e9745f355ae7de0e25beab667f96173f132 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Wed, 9 Oct 2024 12:12:41 +1100 Subject: [PATCH 6/6] Update src/Aspire.Hosting/Codespaces/CodespacesUrlRewriter.cs Co-authored-by: James Newton-King --- src/Aspire.Hosting/Codespaces/CodespacesUrlRewriter.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Aspire.Hosting/Codespaces/CodespacesUrlRewriter.cs b/src/Aspire.Hosting/Codespaces/CodespacesUrlRewriter.cs index 63587167608..e72c3657c2d 100644 --- a/src/Aspire.Hosting/Codespaces/CodespacesUrlRewriter.cs +++ b/src/Aspire.Hosting/Codespaces/CodespacesUrlRewriter.cs @@ -9,7 +9,7 @@ namespace Aspire.Hosting.Codespaces; -internal class CodespacesUrlRewriter(ILogger logger, IConfiguration configuration, ResourceNotificationService resourceNotificationService) : BackgroundService +internal sealed class CodespacesUrlRewriter(ILogger logger, IConfiguration configuration, ResourceNotificationService resourceNotificationService) : BackgroundService { private const string CodespacesEnvironmentVariable = "CODESPACES"; private const string CodespaceNameEnvironmentVariable = "CODESPACE_NAME";