From 11e9f5813b7e8055fceafd852803a2fbe179969f Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Fri, 25 Oct 2024 12:00:48 +1100 Subject: [PATCH] Initial integration of codespace URL rewriting logic into hosting. (#6183) * Initial integration of codespace URL rewriting logic into hosting. * Add unit tests. * Update src/Aspire.Hosting/Codespaces/CodespacesUrlRewriter.cs Co-authored-by: James Newton-King * PR feedback. * Share config acquisition logic/exceptions. * Update src/Aspire.Hosting/Codespaces/CodespacesUrlRewriter.cs Co-authored-by: James Newton-King --------- Co-authored-by: James Newton-King --- .../Codespaces/CodespacesUrlRewriter.cs | 89 +++++++++++++++ .../DistributedApplicationBuilder.cs | 4 + .../Codespaces/CodespacesUrlRewriterTests.cs | 103 ++++++++++++++++++ 3 files changed, 196 insertions(+) create mode 100644 src/Aspire.Hosting/Codespaces/CodespacesUrlRewriter.cs create mode 100644 tests/Aspire.Hosting.Tests/Codespaces/CodespacesUrlRewriterTests.cs diff --git a/src/Aspire.Hosting/Codespaces/CodespacesUrlRewriter.cs b/src/Aspire.Hosting/Codespaces/CodespacesUrlRewriter.cs new file mode 100644 index 0000000000..e72c3657c2 --- /dev/null +++ b/src/Aspire.Hosting/Codespaces/CodespacesUrlRewriter.cs @@ -0,0 +1,89 @@ +// 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; +using Microsoft.Extensions.Logging; + +namespace Aspire.Hosting.Codespaces; + +internal sealed 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(CodespacesEnvironmentVariable, false)) + { + logger.LogTrace("Not running in Codespaces, skipping URL rewriting."); + return; + } + + var gitHubCodespacesPortForwardingDomain = GetRequiredCodespacesConfigurationValue(GitHubCodespacesPortForwardingDomain); + var codespaceName = GetRequiredCodespacesConfigurationValue(CodespaceNameEnvironmentVariable); + + do + { + try + { + 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") + { + remappedUrls ??= new(); + + 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}" + }; + + 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); + } + } + } + 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); + } +} diff --git a/src/Aspire.Hosting/DistributedApplicationBuilder.cs b/src/Aspire.Hosting/DistributedApplicationBuilder.cs index 88ddef4072..cf2d5824f1 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; @@ -267,6 +268,9 @@ public DistributedApplicationBuilder(DistributedApplicationOptions options) _innerBuilder.Services.AddSingleton(new Locations()); _innerBuilder.Services.AddSingleton(); + // Codespaces + _innerBuilder.Services.AddHostedService(); + Eventing.Subscribe(BuiltInDistributedApplicationEventSubscriptionHandlers.InitializeDcpAnnotations); } diff --git a/tests/Aspire.Hosting.Tests/Codespaces/CodespacesUrlRewriterTests.cs b/tests/Aspire.Hosting.Tests/Codespaces/CodespacesUrlRewriterTests.cs new file mode 100644 index 0000000000..b004b8bdf2 --- /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) + { + } +}