Skip to content

Commit

Permalink
Initial integration of codespace URL rewriting logic into hosting. (#…
Browse files Browse the repository at this point in the history
…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
mitchdenny and JamesNK authored Oct 25, 2024
1 parent d2492f5 commit 11e9f58
Show file tree
Hide file tree
Showing 3 changed files with 196 additions and 0 deletions.
89 changes: 89 additions & 0 deletions src/Aspire.Hosting/Codespaces/CodespacesUrlRewriter.cs
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);
}
}
4 changes: 4 additions & 0 deletions src/Aspire.Hosting/DistributedApplicationBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -267,6 +268,9 @@ public DistributedApplicationBuilder(DistributedApplicationOptions options)
_innerBuilder.Services.AddSingleton(new Locations());
_innerBuilder.Services.AddSingleton<IKubernetesService, KubernetesService>();

// Codespaces
_innerBuilder.Services.AddHostedService<CodespacesUrlRewriter>();

Eventing.Subscribe<BeforeStartEvent>(BuiltInDistributedApplicationEventSubscriptionHandlers.InitializeDcpAnnotations);
}

Expand Down
103 changes: 103 additions & 0 deletions tests/Aspire.Hosting.Tests/Codespaces/CodespacesUrlRewriterTests.cs
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)
{
}
}

0 comments on commit 11e9f58

Please sign in to comment.