-
Notifications
You must be signed in to change notification settings - Fork 453
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
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 <[email protected]> * PR feedback. * Share config acquisition logic/exceptions. * Update src/Aspire.Hosting/Codespaces/CodespacesUrlRewriter.cs Co-authored-by: James Newton-King <[email protected]> --------- Co-authored-by: James Newton-King <[email protected]>
- Loading branch information
1 parent
d2492f5
commit 11e9f58
Showing
3 changed files
with
196 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<CodespacesUrlRewriter> 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<string>(key) ?? throw new DistributedApplicationException($"Codespaces was detected but {key} environment missing."); | ||
} | ||
|
||
protected override async Task ExecuteAsync(CancellationToken stoppingToken) | ||
{ | ||
if (!configuration.GetValue<bool>(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<UrlSnapshot, UrlSnapshot>? 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); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
103 changes: 103 additions & 0 deletions
103
tests/Aspire.Hosting.Tests/Codespaces/CodespacesUrlRewriterTests.cs
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<ResourceNotificationService>(); | ||
|
||
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<ResourceNotificationService>(); | ||
|
||
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) | ||
{ | ||
} | ||
} |