Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
69 changes: 69 additions & 0 deletions src/Aspire.Hosting/Codespaces/CodespacesUrlRewriter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
// 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 class CodespacesUrlRewriter(ILogger<CodespacesUrlRewriter> logger, IConfiguration configuration, ResourceNotificationService resourceNotificationService) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
if (!configuration.GetValue<bool>("CODESPACES", false))
{
logger.LogTrace("Not running in Codespaces, skipping URL rewriting.");
return;
}

var gitHubCodespacesPortForwardingDomain = configuration.GetValue<string>("GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN") ?? throw new DistributedApplicationException("Codespaces was detected but GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN environment missing.");
var codespaceName = configuration.GetValue<string>("CODESPACE_NAME") ?? throw new DistributedApplicationException("Codespaces was detected but CODESPACE_NAME environment missing.");

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?

{
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")
Copy link
Member

Choose a reason for hiding this comment

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

Why these values

Copy link
Member Author

Choose a reason for hiding this comment

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

Honestly I don't know what `IsInternal' really means - do you?

As for only exposing http and https schemed endpoints - from what I could tell that is the only option it supported. It doesn't seem to support anything other than HTTP.

Copy link
Member

Choose a reason for hiding this comment

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

Honestly I don't know what `IsInternal' really means - do you?

No

{
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);
Copy link
Member

Choose a reason for hiding this comment

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

What are you doing here? If WatchAsync errors then won't it throw an error right out of this method? Also, there should be a log message if there was an error and you need to recover.

Add a test that covers this situation to verify it works.

}
}
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)
{
}
}