File: GoRunDebuggingTests.cs
Web Access
Project: src\tests\Aspire.Hosting.Go.Tests\Aspire.Hosting.Go.Tests.csproj (Aspire.Hosting.Go.Tests)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.ComponentModel;
using System.Text;
using System.Text.Json;
using Aspire.Hosting.ApplicationModel;
using Aspire.Hosting.Backchannel;
using Aspire.Hosting.Dcp.Process;
using Aspire.Hosting.Diagnostics;
using Aspire.Hosting.Tests.Utils;
using Aspire.Hosting.Utils;
using Aspire.TestUtilities;
using Microsoft.DotNet.RemoteExecutor;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Logging.Testing;
 
namespace Aspire.Hosting.Go.Tests;
 
public class GoRunDebuggingTests(ITestOutputHelper outputHelper)
{
    [Fact]
    public async Task GoFlagsProbeDoesNotResolveUnrelatedApplicationValues()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var runner = new TestProcessRunner();
        runner.EnqueueResult(output: [JsonSerializer.Serialize(new { GOFLAGS = "" })]);
        builder.Services.AddSingleton<IProcessRunner>(runner);
        var go = builder.AddGoApp("api", builder.AppHostDirectory);
        var unrelated = builder.AddParameter("unrelated", () => throw new InvalidOperationException("Unrelated value was resolved."));
 
        await using var app = builder.Build();
        var context = new EnvironmentCallbackContext(builder.ExecutionContext, go.Resource);
        context.EnvironmentVariables["UNRELATED"] = unrelated.Resource;
 
        await GoRunDebugging.ConfigureEnvironmentAsync(context);
    }
 
    [Theory]
    [InlineData(null, false)]
    [InlineData("", false)]
    [InlineData(null, true)]
    public async Task GoFlagsProbeUsesConfiguredCommandAndPreservesItsFlags(string? resourceFlags, bool mixedQuotes)
    {
        var flags = mixedQuotes ? "-tags=wrapper -ldflags=-X=main.version=a'\"b" : "-tags=wrapper -ldflags=-X=main.version=wrapper";
        var expectedFlags = mixedQuotes ? flags :
            "\"-ldflags=-s=false -w=false\" -tags=wrapper -ldflags=-X=main.version=wrapper \"-ldflags=-s=false -w=false -X=main.version=wrapper\"";
        using var builder = TestDistributedApplicationBuilder.Create();
        var command = Path.Combine(builder.AppHostDirectory, "custom go");
        var runner = new TestProcessRunner();
        runner.EnqueueResult(output: [JsonSerializer.Serialize(new { GOFLAGS = flags })]);
        builder.Services.AddSingleton<IProcessRunner>(runner);
        var go = builder.AddGoApp("api", builder.AppHostDirectory)
            .WithCommand(command);
        if (resourceFlags is not null)
        {
            go.WithEnvironment("GOFLAGS", resourceFlags);
        }
 
        var environment = await GetEnvironmentAsync(builder, go.Resource);
 
        var probe = Assert.Single(runner.ProcessSpecs);
        Assert.Equal(command, probe.ExecutablePath);
        Assert.Equal(["env", "-json", "GOFLAGS"], probe.ArgumentList);
        Assert.Equal(expectedFlags, environment["GOFLAGS"]);
    }
 
    [Theory]
    [InlineData(false, false, false)]
    [InlineData(true, false, false)]
    [InlineData(false, true, false)]
    [InlineData(false, true, true)]
    public void GoFlagsProbeFailureDoesNotPreventResourceEnvironmentEvaluation(
        bool processStarted, bool hasAmbientFlags, bool emptyResourceFlags)
    {
        var options = new RemoteInvokeOptions();
        options.StartInfo.Environment.Remove("GOFLAGS");
        if (hasAmbientFlags)
        {
            options.StartInfo.Environment["GOFLAGS"] = "-tags=ambient";
        }
 
        RemoteExecutor.Invoke(static async (processStartedValue, hasAmbientFlagsValue, emptyResourceFlagsValue) =>
        {
            using var builder = TestDistributedApplicationBuilder.Create();
            var runner = new TestProcessRunner();
            var started = bool.Parse(processStartedValue);
            var startFailure = new Win32Exception(2, "The configured Go command could not be started.");
            if (started)
            {
                runner.EnqueueResult(exitCode: 1, output: ["""{"GOFLAGS":"-ldflags=-s"}"""]);
            }
            else
            {
                runner.EnqueueException(startFailure);
            }
 
            builder.Services.AddSingleton<IProcessRunner>(runner);
            var sink = new TestSink(writeEnabled: context => context.LogLevel is LogLevel.Warning or LogLevel.Debug);
            var logger = new TestLogger(nameof(GoRunDebuggingTests), sink, enabled: true);
            var go = builder.AddGoApp("api", builder.AppHostDirectory)
                .WithEnvironment("PUBLIC_VALUE", "unchanged");
            var emptyFlags = bool.Parse(emptyResourceFlagsValue);
            if (emptyFlags)
            {
                go.WithEnvironment("GOFLAGS", "");
            }
 
            await using var app = builder.Build();
            await builder.Eventing.PublishAsync(new BeforeStartEvent(
                app.Services, app.Services.GetRequiredService<DistributedApplicationModel>()));
            var configuration = await ExecutionConfigurationBuilder.Create(go.Resource)
                .WithEnvironmentVariablesConfig()
                .BuildAsync(builder.ExecutionContext, logger);
 
            var expectedFlags = bool.Parse(hasAmbientFlagsValue) && !emptyFlags
                ? "\"-ldflags=-s=false -w=false\" -tags=ambient"
                : "\"-ldflags=-s=false -w=false\"";
            Assert.Null(configuration.Exception);
            Assert.Equal(expectedFlags, Assert.Single(
                configuration.EnvironmentVariables, variable => variable.Key == "GOFLAGS").Value?.Trim());
            Assert.Equal("unchanged", Assert.Single(
                configuration.EnvironmentVariables, variable => variable.Key == "PUBLIC_VALUE").Value);
            var diagnostic = Assert.Single(sink.Writes);
            if (started)
            {
                Assert.IsType<InvalidOperationException>(diagnostic.Exception);
                Assert.Equal(1, Assert.Single(runner.Disposables).DisposeCallCount);
            }
            else
            {
                Assert.Same(startFailure, diagnostic.Exception);
            }
 
            Assert.Contains("GOFLAGS", diagnostic.Message, StringComparison.Ordinal);
        }, processStarted.ToString(), hasAmbientFlags.ToString(), emptyResourceFlags.ToString(), options).Dispose();
    }
 
    [Theory]
    [InlineData("invalid-json")]
    [InlineData("{}")]
    [InlineData("""{"GOFLAGS":null}""")]
    [InlineData("""{"GOFLAGS":42}""")]
    public void InvalidGoFlagsProbeOutputDoesNotPreventEnvironmentEvaluation(string output)
    {
        var options = new RemoteInvokeOptions();
        options.StartInfo.Environment.Remove("GOFLAGS");
 
        RemoteExecutor.Invoke(static async probeOutput =>
        {
            using var builder = TestDistributedApplicationBuilder.Create();
            var runner = new TestProcessRunner();
            runner.EnqueueResult(output: [probeOutput]);
            builder.Services.AddSingleton<IProcessRunner>(runner);
            var go = builder.AddGoApp("api", builder.AppHostDirectory);
 
            var environment = await GetEnvironmentAsync(builder, go.Resource);
 
            Assert.Equal("\"-ldflags=-s=false -w=false\"", environment["GOFLAGS"].Trim());
            Assert.Equal(1, Assert.Single(runner.Disposables).DisposeCallCount);
        }, output, options).Dispose();
    }
 
    [Theory]
    [InlineData(false)]
    [InlineData(true)]
    public async Task GoFlagsProbeCancellationIsPropagated(bool processExitsNonzero)
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        using var cancellation = new CancellationTokenSource();
        var result = new TaskCompletionSource<ProcessResult>(TaskCreationOptions.RunContinuationsAsynchronously);
        var runner = new TestProcessRunner();
        runner.EnqueuePending(result.Task);
        runner.RunCallback = spec =>
        {
            if (processExitsNonzero)
            {
                // ProcessUtil disposal terminates the child and awaits its lifetime result.
                Assert.Single(runner.Disposables).OnDisposeAsync = async () =>
                {
                    if (spec.ThrowOnNonZeroReturnCode)
                    {
                        result.SetException(new InvalidOperationException("The terminated probe exited nonzero."));
                    }
                    else
                    {
                        result.SetResult(new ProcessResult(1));
                    }
 
                    await result.Task;
                };
            }
 
            cancellation.Cancel();
        };
        builder.Services.AddSingleton<IProcessRunner>(runner);
        var go = builder.AddGoApp("api", builder.AppHostDirectory);
        var sink = new TestSink();
        await using var app = builder.Build();
        var context = new EnvironmentCallbackContext(
            builder.ExecutionContext, go.Resource, cancellationToken: cancellation.Token)
        {
            Logger = new TestLogger(nameof(GoRunDebuggingTests), sink, enabled: true)
        };
 
        var exception = await Assert.ThrowsAnyAsync<OperationCanceledException>(
            () => GoRunDebugging.ConfigureEnvironmentAsync(context));
 
        Assert.Equal(cancellation.Token, exception.CancellationToken);
        Assert.Empty(context.EnvironmentVariables);
        Assert.Empty(sink.Writes);
        Assert.Equal(1, Assert.Single(runner.Disposables).DisposeCallCount);
    }
 
    [Theory]
    [InlineData(false, false, false)]
    [InlineData(false, true, false)]
    [InlineData(true, false, false)]
    [InlineData(true, true, false)]
    [InlineData(true, true, true)]
    public async Task ResourceGoFlagsPreserveConfiguredValuesAndSensitivity(
        bool useEnvironmentCallback, bool secret, bool mixedQuotes)
    {
        var flags = mixedQuotes ? "-ldflags=all=-X=main.version=before -tags=netgo -ldflags=-X=main.version=a'\"b" : "-ldflags=-X=main.version=1.2.3";
        var expectedFlags = mixedQuotes ? flags :
            "\"-ldflags=-s=false -w=false\" -ldflags=-X=main.version=1.2.3 \"-ldflags=-s=false -w=false -X=main.version=1.2.3\"";
        using var builder = TestDistributedApplicationBuilder.Create();
        var runner = new TestProcessRunner();
        builder.Services.AddSingleton<IProcessRunner>(runner);
        var sink = new TestSink(writeEnabled: context => context.LogLevel == LogLevel.Warning);
        var logger = new TestLogger(nameof(GoRunDebuggingTests), sink, enabled: true);
        var parameter = builder.AddParameter("linker-flags", () => flags, secret: secret);
        var go = builder.AddGoApp("api", builder.AppHostDirectory)
            .WithEnvironment("PUBLIC_FLAGS", "-tags=netgo");
        if (useEnvironmentCallback)
        {
            go.WithEnvironment(context => context.EnvironmentVariables["GOFLAGS"] = parameter);
        }
        else
        {
            go.WithEnvironment("GOFLAGS", parameter);
        }
 
        await using var app = builder.Build();
        await builder.Eventing.PublishAsync(new BeforeStartEvent(
            app.Services, app.Services.GetRequiredService<DistributedApplicationModel>()));
        var configuration = await ExecutionConfigurationBuilder.Create(go.Resource)
            .WithEnvironmentVariablesConfig()
            .BuildAsync(builder.ExecutionContext, logger);
 
        Assert.Null(configuration.Exception);
        Assert.Equal(expectedFlags, Assert.Single(configuration.EnvironmentVariables, variable => variable.Key == "GOFLAGS").Value);
        if (mixedQuotes)
        {
            Assert.Single(sink.Writes);
        }
        else
        {
            Assert.Empty(sink.Writes);
        }
 
        await app.Services.GetRequiredService<ResourceNotificationService>().PublishUpdateAsync(go.Resource, snapshot => snapshot with
        {
            EnvironmentVariables = [.. configuration.EnvironmentVariables
                .Select(variable => new EnvironmentVariableSnapshot(variable.Key, variable.Value, true))]
        });
        var target = new AuxiliaryBackchannelRpcTarget(
            NullLogger<AuxiliaryBackchannelRpcTarget>.Instance,
            app.Services.GetRequiredService<IConfiguration>(),
            app.Services.GetRequiredService<ProfilingTelemetry>(),
            app.Services);
        var snapshot = Assert.Single(await target.GetResourceSnapshotsAsync(), resource => resource.DisplayName == "api");
 
        Assert.Equal(secret ? null : expectedFlags, Assert.Single(snapshot.EnvironmentVariables, variable => variable.Name == "GOFLAGS").Value);
        Assert.Equal("-tags=netgo", Assert.Single(snapshot.EnvironmentVariables, variable => variable.Name == "PUBLIC_FLAGS").Value);
    }
 
    [Theory]
    [InlineData(false)]
    [InlineData(true)]
    public async Task ExplicitLinkerOptionsAndDelveDoNotReceiveRunDefaults(bool delve)
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var runner = new TestProcessRunner();
        builder.Services.AddSingleton<IProcessRunner>(runner);
        var go = builder.AddGoApp("api", builder.AppHostDirectory, ldFlags: delve ? null : "-s -w");
        if (delve)
        {
            go.WithDelveServer();
        }
 
        var environment = await GetEnvironmentAsync(builder, go.Resource);
 
        Assert.False(environment.ContainsKey("GOFLAGS"));
        Assert.Empty(runner.ProcessSpecs);
    }
 
    [Theory]
    [InlineData("\"-ldflags=-w")]
    [InlineData("-ldflags=-X=main.version=a'\"b \"-tags=broken")]
    public async Task MalformedGoFlagsAreRejected(string flags)
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var go = builder.AddGoApp("api", builder.AppHostDirectory)
            .WithEnvironment("GOFLAGS", flags);
 
        await Assert.ThrowsAsync<FormatException>(() => GetEnvironmentAsync(builder, go.Resource));
    }
 
    [Theory]
    [InlineData(false, "", "unset", false, true, true)]
    [InlineData(false, "-ldflags=-X=main.version=1.2.3", "1.2.3", false, true, true)]
    [InlineData(false, "'-ldflags=-X \"main.version=hello world\"'", "hello world", false, true, true)]
    [InlineData(false, "-ldflags=all=-X=main.version=1.2.3", "1.2.3", false, true, true)]
    [InlineData(true, "-ldflags=-X=main.version=1.2.3", "1.2.3", false, true, true)]
    [InlineData(false, "-tags=stamp -ldflags=-X=main.version=1.2.3", "1.2.3", true, true, true)]
    [InlineData(false, "-ldflags=all=-X=main.version=all -ldflags=example.com/debugflags=-X=main.version=specific", "specific", false, true, true)]
    [InlineData(false, "-ldflags=example.com/debugflags=-X=main.version=specific -ldflags=all=-X=main.version=all", "all", false, true, true)]
    [InlineData(false, "-ldflags=-s", "unset", false, false, false)]
    [InlineData(false, "\"-ldflags=-s -w\"", "unset", false, false, false)]
    [InlineData(false, "-ldflags=-w", "unset", false, true, false)]
    [InlineData(false, "-ldflags=-w=false", "unset", false, true, true)]
    [InlineData(false, "-ldflags=-s=false", "unset", false, true, true)]
    [InlineData(false, "-ldflags=--s", "unset", false, false, false)]
    [InlineData(false, "-ldflags=--w", "unset", false, true, false)]
    [InlineData(false, "-ldflags=--s=false", "unset", false, true, true)]
    [InlineData(false, "-ldflags=--w=false", "unset", false, true, true)]
    [InlineData(false, "-ldflags=-s=1", "unset", false, false, false)]
    [InlineData(false, "-ldflags=--s=0", "unset", false, true, true)]
    [InlineData(false, "\"-ldflags=-s -s=false\"", "unset", false, true, true)]
    [InlineData(false, "\"-ldflags=-w -w=false\"", "unset", false, true, true)]
    // null uses native go run because Windows Go releases differ on whether -s -w=false preserves DWARF.
    // https://github.com/golang/go/blob/go1.24.13/src/cmd/link/internal/ld/pe.go
    [InlineData(false, "\"-ldflags=-s -w=false\"", "unset", false, false, null)]
    [InlineData(false, "\"-ldflags=-L -s\"", "unset", false, true, true)]
    [InlineData(false, "'-ldflags=-L \"-w\"'", "unset", false, true, true)]
    [InlineData(false, "-ldflags=-L=-s", "unset", false, true, true)]
    [InlineData(false, "\"-ldflags=-L -s -s\"", "unset", false, false, false)]
    [InlineData(false, "'-ldflags=-buildid \"-s\"'", "unset", false, true, true)]
    [InlineData(true, "-ldflags=all=-s", "unset", false, false, false)]
    [InlineData(false, "\"-ldflags=-s -s=false -w=false\"", "unset", false, true, true)]
    [InlineData(false, "\"-ldflags= all = -X=main.version=space\"", "space", false, true, true)]
    [InlineData(false, "-ldflags=", "unset", false, true, true)]
    [InlineData(false, "-ldflags=-X=main.version=a'\"b", "a'\"b", false, false, false)]
    [InlineData(true, "-ldflags=-X=main.version=a'\"b", "a'\"b", false, false, false)]
    [RequiresTools(["go"])]
    public async Task GoRunPreservesVersionStampAndDebugSymbolsWhenSupported(
        bool persisted, string flags, string expectedVersion, bool expectedTagged, bool expectedSymbols, bool? expectedDwarf)
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        File.WriteAllText(Path.Combine(workspace.Path, "go.mod"), "module example.com/debugflags\n\ngo 1.18\n");
        File.WriteAllText(Path.Combine(workspace.Path, "main.go"), """
            package main
 
            import (
                "debug/dwarf"
                "debug/elf"
                "debug/macho"
                "debug/pe"
                "encoding/json"
                "os"
                "runtime"
            )
 
            var version = "unset"
            var tagged = false
 
            type executable interface {
                DWARF() (*dwarf.Data, error)
                Close() error
            }
 
            func main() {
                path, err := os.Executable()
                if err != nil { panic(err) }
                var image executable
                var hasSymbols bool
                switch runtime.GOOS {
                case "windows":
                    file, err := pe.Open(path)
                    if err != nil { panic(err) }
                    image = file
                    hasSymbols = len(file.Symbols) > 0
                case "darwin":
                    file, err := macho.Open(path)
                    if err != nil { panic(err) }
                    image = file
                    hasSymbols = file.Symtab != nil && len(file.Symtab.Syms) > 0
                default:
                    file, err := elf.Open(path)
                    if err != nil { panic(err) }
                    image = file
                    symbols, symbolErr := file.Symbols()
                    hasSymbols = symbolErr == nil && len(symbols) > 0
                }
                defer image.Close()
                var hasDwarf bool
                if data, err := image.DWARF(); err == nil {
                    reader := data.Reader()
                    for {
                        entry, err := reader.Next()
                        if err != nil { panic(err) }
                        if entry == nil { break }
                        if entry.Tag == dwarf.TagSubprogram && entry.Val(dwarf.AttrName) == "main.main" {
                            hasDwarf = true
                            break
                        }
                    }
                }
                if err := json.NewEncoder(os.Stdout).Encode(map[string]any{
                    "version": version,
                    "dwarf": hasDwarf,
                    "symbols": hasSymbols,
                    "tagged": tagged,
                }); err != nil { panic(err) }
            }
            """);
        File.WriteAllText(Path.Combine(workspace.Path, "tag.go"), """
            //go:build stamp
 
            package main
 
            func init() { tagged = true }
            """);
 
        using var builder = TestDistributedApplicationBuilder.Create();
        Assert.Null(builder.Configuration["DEBUG_SESSION_PORT"]);
        var go = builder.AddGoApp("api", workspace.Path)
            .WithEnvironment("GOFLAGS", persisted ? "" : flags)
            .WithEnvironment("GOENV", "off");
        if (persisted)
        {
            var goEnvironment = Path.Combine(workspace.Path, "go.env");
            File.WriteAllText(goEnvironment, $"GOFLAGS={flags}\n");
            go.WithEnvironment("GOENV", goEnvironment);
        }
 
        await using var app = builder.Build();
        await builder.Eventing.PublishAsync(new BeforeStartEvent(
            app.Services, app.Services.GetRequiredService<DistributedApplicationModel>()));
        var environment = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(go.Resource, serviceProvider: app.Services);
        var arguments = await ArgumentEvaluator.GetArgumentListAsync(go.Resource, app.Services);
        var processRunner = app.Services.GetRequiredService<IProcessRunner>();
        using var document = await RunGoAsync(processRunner, go.Resource, arguments, environment);
        if (expectedDwarf is null)
        {
            var nativeEnvironment = new Dictionary<string, string>(environment, environment.Comparer)
            {
                ["GOFLAGS"] = flags
            };
            using var nativeDocument = await RunGoAsync(processRunner, go.Resource, ["run", "."], nativeEnvironment);
            expectedDwarf = nativeDocument.RootElement.GetProperty("dwarf").GetBoolean();
        }
 
        Assert.Equal(expectedVersion, document.RootElement.GetProperty("version").GetString());
        Assert.Equal(expectedDwarf.Value, document.RootElement.GetProperty("dwarf").GetBoolean());
        Assert.Equal(expectedSymbols, document.RootElement.GetProperty("symbols").GetBoolean());
        Assert.Equal(expectedTagged, document.RootElement.GetProperty("tagged").GetBoolean());
    }
 
    private static async Task<JsonDocument> RunGoAsync(
        IProcessRunner processRunner, GoAppResource resource, IReadOnlyList<string> arguments, Dictionary<string, string> environment)
    {
        var output = new StringBuilder();
        var (resultTask, process) = processRunner.Run(new ProcessSpec(resource.Command)
        {
            WorkingDirectory = resource.WorkingDirectory,
            ArgumentList = arguments,
            EnvironmentVariables = environment,
            ResolveExecutablePath = true,
            OnOutputData = line => output.AppendLine(line)
        });
        await using (process)
        {
            var result = await resultTask.WaitAsync(TimeSpan.FromMinutes(2));
            Assert.Equal(0, result.ExitCode);
        }
 
        return JsonDocument.Parse(output.ToString());
    }
 
    private static async Task<Dictionary<string, string>> GetEnvironmentAsync(
        IDistributedApplicationBuilder builder, GoAppResource resource)
    {
        await using var app = builder.Build();
        await builder.Eventing.PublishAsync(new BeforeStartEvent(
            app.Services, app.Services.GetRequiredService<DistributedApplicationModel>()));
 
        return await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(resource, serviceProvider: app.Services);
    }
}