Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe add a comment that this runs for the lifetime of the host so it can react to resources as they start and new resources being added?

{
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 @@ -260,6 +261,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)
{
}
}