Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improve auto-forwarding for GitHub Codespaces and devcontainers. #6780

Open
wants to merge 8 commits into
base: main
Choose a base branch
from
Open
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
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,75 @@
// 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";

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

var settingsPath = GetSettingsPath();

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);

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