File: Utils\EnvironmentChecker\GoAppHostToolingCheck.cs
Web Access
Project: src\src\Aspire.Cli\Aspire.Cli.csproj (aspire)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Text;
using System.Text.Json.Nodes;
using System.Text.RegularExpressions;
using Aspire.Cli.DotNet;
using Aspire.Cli.Projects;
using Aspire.Shared;
using Microsoft.Extensions.Logging;
 
namespace Aspire.Cli.Utils.EnvironmentChecker;
 
internal sealed partial class GoAppHostToolingCheck : IEnvironmentCheck
{
    internal const string GoCommandCheckName = "go-apphost-go";
    internal const string VersionCheckName = "go-apphost-version";
    internal const string ToolsCheckName = "go-apphost-tools";
 
    private const string GoCommand = "go";
    private const string ToolchainVariable = "GOTOOLCHAIN";
 
    // The generated Go SDK and the scaffolded Go AppHost both declare `go 1.26` in their go.mod. The SDK's
    // Windows named-pipe transport relies on the overlapped I/O support that os.OpenFile gained in Go 1.26
    // (https://go.dev/doc/go1.26#os). The CLI cannot reference Aspire.Hosting.CodeGeneration.Go, so
    // GoAppHostToolingCheckTests fails when the go.mod templates in AtsGoCodeGenerator and GoLanguageSupport
    // stop matching this value.
    internal static Version MinimumGoVersion { get; } = new(1, 26);
 
    // Go 1.21 added toolchain switching (https://go.dev/doc/toolchain). Earlier releases cannot
    // download a newer toolchain, so they can never satisfy a go.mod that requires a later Go.
    private static readonly Version s_toolchainSwitchingVersion = new(1, 21);
 
    private static readonly TimeSpan s_probeTimeout = TimeSpan.FromSeconds(5);
 
    private readonly IProjectLocator _projectLocator;
    private readonly ILanguageDiscovery _languageDiscovery;
    private readonly CliExecutionContext _executionContext;
    private readonly IEnvironment _environment;
    private readonly Func<string, string?> _commandResolver;
    private readonly Func<string, CancellationToken, Task<string?>> _versionResolver;
    private readonly Func<string, string, CancellationToken, Task<string?>> _toolchainResolver;
 
    public GoAppHostToolingCheck(
        IProjectLocator projectLocator,
        ILanguageDiscovery languageDiscovery,
        CliExecutionContext executionContext,
        IEnvironment environment,
        IProcessExecutionFactory processExecutionFactory,
        ILogger<GoAppHostToolingCheck> logger)
        : this(
            projectLocator,
            languageDiscovery,
            executionContext,
            environment,
            PathLookupHelper.FindFullPathFromPath,
            (path, cancellationToken) => RunProbeAsync(processExecutionFactory, CreateVersionProbeStartInfo(path), s_probeTimeout, logger, cancellationToken),
            (path, workingDirectory, cancellationToken) => RunProbeAsync(processExecutionFactory, CreateToolchainProbeStartInfo(path, workingDirectory), s_probeTimeout, logger, cancellationToken))
    {
    }
 
    internal GoAppHostToolingCheck(
        IProjectLocator projectLocator,
        ILanguageDiscovery languageDiscovery,
        CliExecutionContext executionContext,
        IEnvironment environment,
        Func<string, string?> commandResolver,
        Func<string, CancellationToken, Task<string?>> versionResolver,
        Func<string, string, CancellationToken, Task<string?>> toolchainResolver)
    {
        _projectLocator = projectLocator;
        _languageDiscovery = languageDiscovery;
        _executionContext = executionContext;
        _environment = environment;
        _commandResolver = commandResolver;
        _versionResolver = versionResolver;
        _toolchainResolver = toolchainResolver;
    }
 
    public int Order => 33;
 
    public async Task<IReadOnlyList<EnvironmentCheckResult>> CheckAsync(CancellationToken cancellationToken = default)
    {
        var appHostFile = await ResolveGoAppHostAsync(cancellationToken);
        if (appHostFile is null)
        {
            return [];
        }
 
        if (!CommandPathResolver.TryResolveCommand(GoCommand, _commandResolver, out var goPath, out var errorMessage))
        {
            return
            [
                new EnvironmentCheckResult
                {
                    Category = EnvironmentCheckCategories.Environment,
                    Name = GoCommandCheckName,
                    Status = EnvironmentCheckStatus.Fail,
                    Message = $"Go AppHost requires '{GoCommand}'.",
                    Details = errorMessage,
                    Fix = "Install Go and rerun 'aspire doctor'.",
                    Link = CommandPathResolver.GetInstallationLink(GoCommand),
                    Metadata = CreateMetadata(appHostFile, version: null)
                }
            ];
        }
 
        var versionOutput = await _versionResolver(goPath!, cancellationToken);
        if (!TryParseGoVersion(versionOutput, out var version))
        {
            // Inconclusive rather than proven broken: a slow first run or an unusual build can leave
            // `go version` without a readable version while Go itself works.
            return
            [
                CreateVersionResult(
                    appHostFile,
                    version: null,
                    EnvironmentCheckStatus.Warning,
                    "The installed Go version could not be determined.",
                    "Check that 'go version' prints a version and rerun 'aspire doctor'.")
            ];
        }
 
        if (version < MinimumGoVersion)
        {
            return [await CreateOutdatedGoResultAsync(appHostFile, goPath!, version, cancellationToken)];
        }
 
        return
        [
            new EnvironmentCheckResult
            {
                Category = EnvironmentCheckCategories.Environment,
                Name = ToolsCheckName,
                Status = EnvironmentCheckStatus.Pass,
                Message = $"Go AppHost tooling found ({GoCommand} {version}).",
                Metadata = CreateMetadata(appHostFile, version)
            }
        ];
    }
 
    private async Task<FileInfo?> ResolveGoAppHostAsync(CancellationToken cancellationToken)
    {
        // A configured AppHost is authoritative: when settings select another language's AppHost, that one
        // runs, so an unrelated apphost.go in the working directory must not require Go.
        var configuredAppHost = await _projectLocator.GetAppHostFromSettingsAsync(cancellationToken);
        if (configuredAppHost is not null)
        {
            return IsGoLanguage(_languageDiscovery.GetLanguageByFile(configuredAppHost)) ? configuredAppHost : null;
        }
 
        // Look for apphost.go directly. DetectLanguageRecursiveAsync returns only the first language that
        // matches, and C# matches any *.csproj, so a Go AppHost beside an ordinary service project would be
        // reported as C#. GetLanguageById returns null while experimentalPolyglotGo is off, which keeps the
        // lookup gated on the flag.
        var goLanguage = _languageDiscovery.GetLanguageById(new LanguageId(KnownLanguageId.Go));
        var discoveredPath = goLanguage?.FindInDirectory(_executionContext.WorkingDirectory.FullName);
        return discoveredPath is not null ? new FileInfo(discoveredPath) : null;
    }
 
    private static bool IsGoLanguage(LanguageInfo? language)
    {
        return language is not null &&
            language.LanguageId.Value.Equals(KnownLanguageId.Go, StringComparison.OrdinalIgnoreCase);
    }
 
    private async Task<EnvironmentCheckResult> CreateOutdatedGoResultAsync(
        FileInfo appHostFile,
        string goPath,
        Version version,
        CancellationToken cancellationToken)
    {
        if (version < s_toolchainSwitchingVersion)
        {
            return CreateVersionResult(
                appHostFile,
                version,
                EnvironmentCheckStatus.Fail,
                $"Go {version} is installed, and Go versions before {s_toolchainSwitchingVersion} cannot download a newer toolchain.",
                $"Install Go {MinimumGoVersion} or later and rerun 'aspire doctor'.");
        }
 
        var toolchain = await GetEffectiveToolchainAsync(goPath, cancellationToken);
 
        // `local` always runs the Go on PATH and never switches, so an older Go cannot satisfy the AppHost's
        // go.mod.
        if (toolchain is "local")
        {
            return CreateVersionResult(
                appHostFile,
                version,
                EnvironmentCheckStatus.Fail,
                $"Go {version} is installed, and GOTOOLCHAIN=local prevents Go from switching to a newer toolchain.",
                $"Install Go {MinimumGoVersion} or later, or change GOTOOLCHAIN to auto, then rerun 'aspire doctor'.");
        }
 
        if (OnlySwitchesToPathToolchain(toolchain))
        {
            var requiredToolchain = $"go{MinimumGoVersion}.0";
            return CreateVersionResult(
                appHostFile,
                version,
                EnvironmentCheckStatus.Warning,
                $"Go {version} is installed, and GOTOOLCHAIN={toolchain} only lets Go switch to a newer toolchain that is already on PATH (for example {requiredToolchain}).",
                $"Install Go {MinimumGoVersion} or later, or put the {requiredToolchain} toolchain on PATH, then rerun 'aspire doctor'.");
        }
 
        // With the default GOTOOLCHAIN=auto, `go run` downloads the toolchain that go.mod requires.
        return CreateVersionResult(
            appHostFile,
            version,
            EnvironmentCheckStatus.Warning,
            $"Go {version} is installed. Go downloads the Go {MinimumGoVersion} toolchain the first time the AppHost runs, which needs network access.",
            $"Install Go {MinimumGoVersion} or later to avoid the download, then rerun 'aspire doctor'.");
    }
 
    private async Task<string> GetEffectiveToolchainAsync(string goPath, CancellationToken cancellationToken)
    {
        if (_environment.GetEnvironmentVariable(ToolchainVariable) is { Length: > 0 } environmentToolchain)
        {
            return environmentToolchain;
        }
 
        // The go command takes GOTOOLCHAIN from the process environment, then from the user config that
        // `go env -w` writes, then from $GOROOT/go.env (https://go.dev/doc/toolchain). Fedora's Go package
        // ships `local` in go.env (https://developer.fedoraproject.org/tech/languages/go/go-installation.html),
        // so ask go for the effective value. Run it in a fresh empty directory, never the AppHost's: go
        // commands that read a go.mod or go.work can switch toolchains and download one, which doctor must
        // not do. A pinned GOTOOLCHAIN=<name> is not modelled; the check validates the Go on PATH.
        var probeDirectory = Directory.CreateTempSubdirectory("aspire-go-doctor-");
 
        try
        {
            var output = await _toolchainResolver(goPath, probeDirectory.FullName, cancellationToken);
 
            // An unreadable answer means the default, which keeps the download warning.
            return string.IsNullOrWhiteSpace(output) ? "auto" : output.Trim();
        }
        finally
        {
            try
            {
                probeDirectory.Delete(recursive: true);
            }
            catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
            {
                // Best effort: an empty leftover directory in the temp folder is harmless.
            }
        }
    }
 
    // `path` is shorthand for `local+path`. The path modes never download, but they can switch to a
    // toolchain that is already installed: the go command looks in PATH for the exact toolchain that go.mod
    // names (the go line `go 1.26` becomes go1.26.0) and fails only when that executable is missing
    // (cmd/go/internal/toolchain/select.go, https://go.dev/doc/toolchain). Doctor does not read go.mod, so
    // these settings are inconclusive. The values are case-sensitive in the go command, so match them exactly.
    private static bool OnlySwitchesToPathToolchain(string toolchain)
    {
        return toolchain is "path" || toolchain.EndsWith("+path", StringComparison.Ordinal);
    }
 
    private static EnvironmentCheckResult CreateVersionResult(
        FileInfo appHostFile,
        Version? version,
        EnvironmentCheckStatus status,
        string details,
        string fix)
    {
        return new EnvironmentCheckResult
        {
            Category = EnvironmentCheckCategories.Environment,
            Name = VersionCheckName,
            Status = status,
            Message = $"Go AppHost requires Go {MinimumGoVersion} or later.",
            Details = details,
            Fix = fix,
            Link = CommandPathResolver.GetInstallationLink(GoCommand),
            Metadata = CreateMetadata(appHostFile, version)
        };
    }
 
    private static JsonObject CreateMetadata(FileInfo appHostFile, Version? version)
    {
        var metadata = new JsonObject
        {
            ["language"] = KnownLanguageId.Go,
            ["appHostPath"] = appHostFile.FullName
        };
 
        if (version is not null)
        {
            metadata["version"] = version.ToString();
        }
 
        return metadata;
    }
 
    // `go version` prints a single line such as:
    //   go version go1.26.1 windows/amd64
    //   go version go1.26rc1 linux/amd64
    //   go version devel go1.27-4d2f8e1a5b Tue Jan 6 10:00:00 2026 +0000 linux/amd64
    // Release candidates and betas have no patch number and development builds follow the
    // major.minor with a commit hash, so the first go<major>.<minor>[.<patch>] token is the version.
    // Output without such a token is treated as unreadable.
    internal static bool TryParseGoVersion(string? output, [NotNullWhen(true)] out Version? version)
    {
        version = null;
        return !string.IsNullOrWhiteSpace(output) &&
            GoVersionRegex().Match(output) is { Success: true } match &&
            Version.TryParse(match.Groups["version"].Value, out version);
    }
 
    [GeneratedRegex(@"\bgo(?<version>\d+\.\d+(?:\.\d+)?)", RegexOptions.CultureInvariant)]
    private static partial Regex GoVersionRegex();
 
    // The version probe reports the installed toolchain: every go command, including `go version`, runs
    // toolchain selection first. With the default GOTOOLCHAIN=auto, an older Go would download the
    // toolchain that a go.mod in the current directory requires and report that version instead of the
    // installed one, and the download can outlast the timeout. `local` makes it report the installed
    // toolchain without touching the network.
    internal static ProcessStartInfo CreateVersionProbeStartInfo(string executablePath)
    {
        var startInfo = new ProcessStartInfo(executablePath, ["version"]);
 
        startInfo.Environment[ToolchainVariable] = "local";
 
        return startInfo;
    }
 
    // Unlike the version probe, this one must not override GOTOOLCHAIN: it reads the effective setting.
    internal static ProcessStartInfo CreateToolchainProbeStartInfo(string executablePath, string workingDirectory)
    {
        return new ProcessStartInfo(executablePath, ["env", ToolchainVariable])
        {
            WorkingDirectory = workingDirectory
        };
    }
 
    // The factory launches go like every other CLI child process (pinned output encoding, ASPIRE_CLI_*
    // identity variables stripped, debug logging) and force-kills it when the timeout cancels the wait.
    // Only the command line, environment and working directory of the start info are used. The output
    // streams get separate buffers: go reports notices such as a toolchain download on standard error,
    // and they must not end up in the answer that the callers parse.
    internal static async Task<string?> RunProbeAsync(
        IProcessExecutionFactory processExecutionFactory,
        ProcessStartInfo startInfo,
        TimeSpan timeout,
        ILogger logger,
        CancellationToken cancellationToken)
    {
        using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
        timeoutCts.CancelAfter(timeout);
 
        var arguments = string.Join(' ', startInfo.ArgumentList);
        var standardOutput = new StringBuilder();
        var standardError = new StringBuilder();
 
        try
        {
            await using var process = processExecutionFactory.CreateExecution(
                startInfo,
                new ProcessInvocationOptions
                {
                    StandardOutputCallback = line => standardOutput.AppendLine(line),
                    StandardErrorCallback = line => standardError.AppendLine(line)
                });
 
            if (!await process.StartAsync(timeoutCts.Token).ConfigureAwait(false))
            {
                return null;
            }
 
            var exitCode = await process.WaitForExitAsync(timeoutCts.Token).ConfigureAwait(false);
 
            if (exitCode == 0)
            {
                return standardOutput.ToString();
            }
 
            logger.LogDebug(
                "'go {Arguments}' failed with exit code {ExitCode}: {Error}",
                arguments,
                exitCode,
                standardError.ToString().Trim());
        }
        catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
        {
            logger.LogDebug("'go {Arguments}' timed out after {Timeout}s.", arguments, timeout.TotalSeconds);
        }
        catch (Exception ex) when (ex is System.ComponentModel.Win32Exception or InvalidOperationException or IOException)
        {
            logger.LogDebug(ex, "Could not start 'go {Arguments}' using '{GoPath}'.", arguments, startInfo.FileName);
        }
 
        return null;
    }
}