File: Devcontainers\DevcontainerSettingsWriter.cs
Web Access
Project: src\src\Aspire.Hosting\Aspire.Hosting.csproj (Aspire.Hosting)
// 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;
using Aspire.Hosting.Devcontainers.Codespaces;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
 
namespace Aspire.Hosting.Devcontainers;
 
internal class DevcontainerSettingsWriter(ILogger<DevcontainerSettingsWriter> logger, IOptions<CodespacesOptions> codespaceOptions, IOptions<DevcontainersOptions> devcontainerOptions)
{
    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 readonly SemaphoreSlim _writeLock = new SemaphoreSlim(1);
 
    public async Task SetPortAttributesAsync(int port, string protocol, string label, CancellationToken cancellationToken = default)
    {
        ArgumentNullException.ThrowIfNullOrEmpty(protocol);
        ArgumentNullException.ThrowIfNullOrEmpty(label);
 
        var settingsPath = GetSettingsPath();
 
        var acquired = await _writeLock.WaitAsync(WriteLockTimeoutMs, cancellationToken).ConfigureAwait(false);
 
        if (!acquired)
        {
            throw new DistributedApplicationException($"Failed to acquire semaphore for settings file: {settingsPath}");
        }
 
        await EnsureSettingsFileExists(settingsPath, cancellationToken).ConfigureAwait(false);
 
        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);
 
        _writeLock.Release();
 
        string GetSettingsPath()
        {
            // For some reason the machine settings path is different between Codespaces and local Devcontainers
            // so we decide which one to use here based on the options.
            if (codespaceOptions.Value.IsCodespace)
            {
                return CodespaceSettingsPath;
            }
            else if (devcontainerOptions.Value.IsDevcontainer)
            {
                return LocalDevcontainerSettingsPath;
            }
            else
            {
                throw new DistributedApplicationException("Codespaces or Devcontainer not detected.");
            }
        }
 
        async Task EnsureSettingsFileExists(string path, CancellationToken cancellationToken)
        {
            try
            {
                if (!File.Exists(path))
                {
                    // The extra ceremony here is to avoid accidentally overwriting the file if it was
                    // created after we checked for its existence. If the file exists when we go to write
                    // it then we will throw and log a warning, but otherwise continue executing.
                    using var stream = File.Open(path, FileMode.CreateNew);
                    using var writer = new StreamWriter(stream);
                    await writer.WriteAsync("{}".AsMemory(), cancellationToken).ConfigureAwait(false);
                }
            }
            catch (IOException ex) when (ex.Message == $"The file '{path}' already exists.")
            {
                // This is OK, but it should be rare enough that if it starts happening we probably
                // want to know about it in logs that end users submit so we know to take a closer
                // look at what is going on.
                logger.LogWarning("Race condition detected when creating Devcontainer settings file: {Path}", path);
            }
        }
    }
}