Skip to content

Commit

Permalink
Improve auto-forwarding for GitHub Codespaces and devcontainers. (#6780)
Browse files Browse the repository at this point in the history
Improve auto-forwarding for GitHub Codespaces and devcontainers. (#6780)
  • Loading branch information
mitchdenny authored Dec 5, 2024
1 parent d9a9826 commit c331e53
Show file tree
Hide file tree
Showing 16 changed files with 260 additions and 67 deletions.
72 changes: 18 additions & 54 deletions .devcontainer/contributing/devcontainer.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,91 +7,55 @@
"features": {
"ghcr.io/devcontainers/features/azure-cli:1": {},
"ghcr.io/azure/azure-dev/azd:0": {},
"ghcr.io/devcontainers/features/docker-in-docker:2": {},
"ghcr.io/devcontainers/features/powershell:1": {},
"ghcr.io/devcontainers/features/docker-in-docker": {},
"ghcr.io/devcontainers/features/dotnet": {
"additionalVersions": [
"8.0.403"
]
],
},
"ghcr.io/devcontainers/features/node:1": {},
"ghcr.io/devcontainers/features/python:1": {},
"ghcr.io/devcontainers/features/node:1": {},
"ghcr.io/devcontainers/features/python:1": {},
"ghcr.io/dapr/cli/dapr-cli": {
"version": "1.12.0"
}
},

"hostRequirements": {
"cpus": 8,
"memory": "32gb",
"storage": "64gb"
},

// Features to add to the dev container. More info: https://containers.dev/features.
// "features": {},

// Use 'forwardPorts' to make a list of ports inside the container available locally.
"forwardPorts": [
15887,
5180,
7024,
15551,
33803,
5350,
41567,
15306
],
"portsAttributes": {
"5180": {
"label": "WaitFor Playground: ApiService",
"protocol": "http"
},
"5350": {
"label": "Redis Playground: Api Service"
},
"7024": {
"label": "WaitFor Playground: Frontend",
"protocol": "https"
},
"15306": {
"label": "Redis Playground: App Host"
},
"15551": {
"label": "WaitFor Playground: PGAdmin",
"protocol": "http"
},
"15887": {
"label": "WaitFor Playground: AppHost",
"protocol": "https"
},
"33803": {
"label": "Redis Playground: Redis Commander"
},
"41567": {
"label": "Redis Playground: Redis Insight"
}
},
"otherPortsAttributes": {
"onAutoForward": "ignore"
},
// "forwardPorts": [
// ],
// "portsAttributes": {
// },

// Use 'postCreateCommand' to run commands after the container is created.
"customizations": {
"vscode": {
"extensions": [
"ms-dotnettools.vscodeintellicode-csharp",
"ms-dotnettools.csdevkit",
"ms-azuretools.vscode-bicep",
"EditorConfig.EditorConfig",
"ms-azuretools.azure-dev",
"GitHub.copilot",
"GitHub.copilot-chat"
"ms-azuretools.azure-dev"
],
"settings": {
"remote.autoForwardPorts": false,
"remote.autoForwardPorts": true,
"remote.autoForwardPortsSource": "output",
"dotnet.defaultSolution": "Aspire.sln"
}
}
},
"onCreateCommand": "dotnet restore",
"postStartCommand": "dotnet dev-certs https --trust"

// Configure tool-specific properties.
// "customizations": {},

// Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
// "remoteUser": "root"
}
6 changes: 5 additions & 1 deletion .devcontainer/dogfooding-nightly/devcontainer.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,11 @@
"ms-azuretools.vscode-bicep",
"GitHub.copilot-chat",
"GitHub.copilot"
]
],
"settings": {
"remote.autoForwardPorts": true,
"remote.autoForwardPortsSource": "output"
}
}
},
"workspaceFolder": "/workspaces/dogfood", // Empty directory for clean testing
Expand Down
2 changes: 1 addition & 1 deletion playground/waitfor/WaitForSandbox.AppHost/appsettings.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"Default": "Information",
"Microsoft.AspNetCore": "Warning",
"Aspire.Hosting.Dcp": "Warning",
"Aspire.Hosting": "Trace"
"Aspire.Hosting": "Information"
}
}
}
1 change: 1 addition & 0 deletions src/Aspire.Dashboard/DashboardWebApplication.cs
Original file line number Diff line number Diff line change
Expand Up @@ -336,6 +336,7 @@ public DashboardWebApplication(
// DOTNET_RUNNING_IN_CONTAINER is a well-known environment variable added by official .NET images.
// https://learn.microsoft.com/dotnet/core/tools/dotnet-environment-variables#dotnet_running_in_container-and-dotnet_running_in_containers
var isContainer = _app.Configuration.GetBool("DOTNET_RUNNING_IN_CONTAINER") ?? false;

LoggingHelpers.WriteDashboardUrl(_logger, frontendEndpointInfo.GetResolvedAddress(replaceIPAnyWithLocalhost: true), options.Frontend.BrowserToken, isContainer);
}
}
Expand Down
16 changes: 14 additions & 2 deletions src/Aspire.Hosting/Dashboard/DashboardLifecycleHook.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
using Aspire.Dashboard.ConsoleLogs;
using Aspire.Dashboard.Model;
using Aspire.Hosting.ApplicationModel;
using Aspire.Hosting.Codespaces;
using Aspire.Hosting.Devcontainers.Codespaces;
using Aspire.Hosting.Dcp;
using Aspire.Hosting.Lifecycle;
using Aspire.Hosting.Utils;
Expand All @@ -18,6 +18,7 @@
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Aspire.Hosting.Devcontainers;

namespace Aspire.Hosting.Dashboard;

Expand All @@ -31,7 +32,9 @@ internal sealed class DashboardLifecycleHook(IConfiguration configuration,
ILoggerFactory loggerFactory,
DcpNameGenerator nameGenerator,
IHostApplicationLifetime hostApplicationLifetime,
CodespacesUrlRewriter codespaceUrlRewriter) : IDistributedApplicationLifecycleHook, IAsyncDisposable
CodespacesUrlRewriter codespaceUrlRewriter,
IOptions<CodespacesOptions> codespacesOptions,
IOptions<DevcontainersOptions> devcontainersOptions) : IDistributedApplicationLifecycleHook, IAsyncDisposable
{
private Task? _dashboardLogsTask;
private CancellationTokenSource? _dashboardLogsCts;
Expand Down Expand Up @@ -244,6 +247,15 @@ private void ConfigureAspireDashboardResource(IResource dashboardResource)

var dashboardUrl = codespaceUrlRewriter.RewriteUrl(firstDashboardUrl.ToString());

if (codespacesOptions.Value.IsCodespace || devcontainersOptions.Value.IsDevcontainer)
{
await DevcontainerPortForwardingHelper.SetPortAttributesAsync(
firstDashboardUrl.Port,
firstDashboardUrl.Scheme,
"aspire-dashboard",
context.CancellationToken).ConfigureAwait(false);
}

distributedApplicationLogger.LogInformation("Now listening on: {DashboardUrl}", dashboardUrl.TrimEnd('/'));

if (!string.IsNullOrEmpty(browserToken))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Options;

namespace Aspire.Hosting.Codespaces;
namespace Aspire.Hosting.Devcontainers.Codespaces;

/// <summary>
/// GitHub Codespaces configuration values.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;

namespace Aspire.Hosting.Codespaces;
namespace Aspire.Hosting.Devcontainers.Codespaces;

internal sealed class CodespacesResourceUrlRewriterService(ILogger<CodespacesResourceUrlRewriterService> logger, IOptions<CodespacesOptions> options, CodespacesUrlRewriter codespaceUrlRewriter, ResourceNotificationService resourceNotificationService) : BackgroundService
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

using Microsoft.Extensions.Options;

namespace Aspire.Hosting.Codespaces;
namespace Aspire.Hosting.Devcontainers.Codespaces;

internal sealed class CodespacesUrlRewriter(IOptions<CodespacesOptions> options)
{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Globalization;
using System.Text.Json.Nodes;

namespace Aspire.Hosting.Devcontainers;

internal class DevcontainerPortForwardingHelper
{
private const string CodespaceSettingsPath = "/home/vscode/.vscode-remote/data/Machine/settings.json";
private const string LocalDevcontainerSettingsPath = "/home/vscode/.vscode-server/data/Machine/settings.json";
private const string PortAttributesFieldName = "remote.portsAttributes";
private const int WriteLockTimeoutMs = 2000;
private static readonly SemaphoreSlim s_writeLock = new SemaphoreSlim(1);

public static async Task SetPortAttributesAsync(int port, string protocol, string label, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNullOrEmpty(protocol);
ArgumentNullException.ThrowIfNullOrEmpty(label);

var settingsPath = GetSettingsPath();

var acquired = await s_writeLock.WaitAsync(WriteLockTimeoutMs, cancellationToken).ConfigureAwait(false);

if (!acquired)
{
throw new DistributedApplicationException($"Failed to acquire semaphore for settings file: {settingsPath}");
}

var settingsContent = await File.ReadAllTextAsync(settingsPath, cancellationToken).ConfigureAwait(false);
var settings = (JsonObject)JsonObject.Parse(settingsContent)!;

JsonObject? portsAttributes;
if (!settings.TryGetPropertyValue(PortAttributesFieldName, out var portsAttributesNode))
{
portsAttributes = new JsonObject();
settings.Add(PortAttributesFieldName, portsAttributes);
}
else
{
portsAttributes = (JsonObject)portsAttributesNode!;
}

var portAsString = port.ToString(CultureInfo.InvariantCulture);

JsonObject? portAttributes;
if (!portsAttributes.TryGetPropertyValue(portAsString, out var portAttributeNode))
{
portAttributes = new JsonObject();
portsAttributes.Add(portAsString, portAttributes);
}
else
{
portAttributes = (JsonObject)portAttributeNode!;
}

portAttributes["label"] = label;
portAttributes["protocol"] = protocol;
portAttributes["onAutoForward"] = "notify";

settingsContent = settings.ToString();
await File.WriteAllTextAsync(settingsPath, settingsContent, cancellationToken).ConfigureAwait(false);

s_writeLock.Release();

static string GetSettingsPath()
{
// For some reason the machine settings path is different between Codespaces and local Devcontainers
// so we probe for it here. We could use options to figure out which one to look for here but that
// would require taking a dependency on the options system here which seems like overkill.
if (File.Exists(CodespaceSettingsPath))
{
return CodespaceSettingsPath;
}
else if (File.Exists(LocalDevcontainerSettingsPath))
{
return LocalDevcontainerSettingsPath;
}
else
{
throw new DistributedApplicationException("Could not find a devcontainer settings file.");
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
// 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.ApplicationModel;
using Aspire.Hosting.Devcontainers.Codespaces;
using Aspire.Hosting.Lifecycle;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;

namespace Aspire.Hosting.Devcontainers;

internal sealed class DevcontainerPortForwardingLifecycleHook : IDistributedApplicationLifecycleHook
{
private readonly ILogger _hostingLogger;
private readonly IOptions<CodespacesOptions> _codespacesOptions;
private readonly IOptions<DevcontainersOptions> _devcontainersOptions;

public DevcontainerPortForwardingLifecycleHook(ILoggerFactory loggerFactory, IOptions<CodespacesOptions> codespacesOptions, IOptions<DevcontainersOptions> devcontainersOptions)
{
_hostingLogger = loggerFactory.CreateLogger("Aspire.Hosting");
_codespacesOptions = codespacesOptions;
_devcontainersOptions = devcontainersOptions;
}

public async Task AfterEndpointsAllocatedAsync(DistributedApplicationModel appModel, CancellationToken cancellationToken)
{
if (!_devcontainersOptions.Value.IsDevcontainer && !_codespacesOptions.Value.IsCodespace)
{
// We aren't a codespace so there is nothing to do here.
return;
}

foreach (var resource in appModel.Resources)
{
if (resource.Name == KnownResourceNames.AspireDashboard)
{
// We don't configure the dashboard here because if we print out the URL
// the dashboard will launch immediately but it hasn't actually started
// which would lead to a poor experience. So we'll let the dashboard
// URL writing logic call the helper directly.
continue;
}

if (resource is not IResourceWithEndpoints resourceWithEndpoints)
{
continue;
}

foreach (var endpoint in resourceWithEndpoints.Annotations.OfType<EndpointAnnotation>())
{
if (_codespacesOptions.Value.IsCodespace && !(endpoint.UriScheme is "https" or "http"))
{
// Codespaces only does port forwarding over HTTPS. If the protocol is not HTTP or HTTPS
// it cannot be forwarded because it can't intercept access to the endpoint without breaking
// the non-HTTP protocol to do GitHub auth.
continue;
}

// TODO: This is inefficient because we are opening the file, parsing it, updating it
// and writing it each time. Its like this for now beause I need to use the logic
// in a few places (here and when we print out the Dashboard URL) - but will need
// to come back and optimize this to support some kind of batching.
await DevcontainerPortForwardingHelper.SetPortAttributesAsync(
endpoint.AllocatedEndpoint!.Port,
endpoint.UriScheme,
$"{resource.Name}-{endpoint.Name}",
cancellationToken).ConfigureAwait(false);

_hostingLogger.LogInformation("Port forwarding: {Url}", endpoint.AllocatedEndpoint!.UriString);
}
}
}
}
Loading

0 comments on commit c331e53

Please sign in to comment.