File: Helpers\ConfigSourceTestContext.cs
Web Access
Project: src\tests\Aspire.Cli.EndToEnd.Tests\Aspire.Cli.EndToEnd.Tests.csproj (Aspire.Cli.EndToEnd.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.Text.Json.Nodes;
using System.Text.RegularExpressions;
using Hex1b.Automation;
using Xunit;
 
namespace Aspire.Cli.EndToEnd.Tests.Helpers;
 
internal sealed class ConfigSourceTestContext
{
    private const string NewCommand = "aspire new aspire-empty --language csharp --name SourceApp --output SourceApp " +
        "--suppress-agent-init --localhost-tld false";
    private const string SshRepository = "ssh://git@config-source.example/config";
 
    private readonly string _root;
    private readonly Hex1bTerminalAutomator _auto;
    private readonly SequenceCounter _counter;
    private readonly CancellationToken _cancellationToken;
    private string _repository;
    private string _configurationRelativePath = "SourceApp/aspire.config.json";
 
    private ConfigSourceTestContext(
        TemporaryWorkspace workspace, Hex1bTerminalAutomator auto, SequenceCounter counter, CancellationToken cancellationToken)
    {
        _root = workspace.WorkspaceRoot.FullName;
        _auto = auto;
        _counter = counter;
        _cancellationToken = cancellationToken;
        _repository = CliE2ETestHelpers.ToContainerPath(Path.Combine(_root, "configuration source"), workspace);
    }
 
    public static async Task RunAsync(ITestOutputHelper output, Func<ConfigSourceTestContext, Task> test)
    {
        var cancellationToken = TestContext.Current.CancellationToken;
        var repoRoot = CliE2ETestHelpers.GetRepoRoot();
        var strategy = CliInstallStrategy.Detect(output.WriteLine);
        if (strategy.Mode is not (CliInstallMode.LocalHive or CliInstallMode.PullRequest or CliInstallMode.LocalArchive))
        {
            Assert.Skip("Configuration sources require a current source build. Run with ASPIRE_E2E_ARCHIVE or in pull request CI.");
        }
 
        using var workspace = TemporaryWorkspace.Create(output);
        using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, workspace: workspace);
        var counter = new SequenceCounter();
        var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500));
        var context = new ConfigSourceTestContext(workspace, auto, counter, cancellationToken);
        await context.WriteSourceAsync("original");
        await File.WriteAllTextAsync(Path.Combine(context._root, "check-source.cs"), SourceCheckProgram, cancellationToken);
        await using var terminalRun = CliE2ETestHelpers.StartRun(terminal, workspace, auto, counter, output, cancellationToken);
        await auto.PrepareDockerEnvironmentAsync(counter, workspace);
        await auto.InstallAspireCliAsync(strategy, counter);
        await context.InitializeRepositoryAsync();
        await test(context);
    }
 
    public async Task CreateProjectAsync()
    {
        await _auto.RunCommandAsync($"{NewCommand} --use-config-source true --non-interactive", _counter,
            timeout: TimeSpan.FromMinutes(3));
        await ConfigureProjectAsync();
    }
 
    public async Task InitializeSolutionAsync()
    {
        await _auto.RunCommandAsync("mkdir SourceApp && cd SourceApp && dotnet new sln --name SourceApp", _counter);
        await _auto.RunCommandAsync(
            "aspire init --language csharp --suppress-agent-init --use-config-source true --non-interactive",
            _counter, timeout: TimeSpan.FromMinutes(3));
        await _auto.RunCommandAsync(
            "test ! -f aspire.config.json && test -f SourceApp.AppHost/aspire.config.json && " +
            "cd SourceApp.AppHost && aspire config set docs.custom keep", _counter);
        _configurationRelativePath = "SourceApp/SourceApp.AppHost/aspire.config.json";
        var configuration = await ReadConfigurationAsync();
        Assert.Equal("SourceApp.AppHost.csproj", configuration["appHost"]!["path"]!.GetValue<string>());
    }
 
    public async Task CreateProjectWithNestedAppHostAsync()
    {
        await CreateProjectAsync();
        // The empty C# template puts the AppHost at the root. Keep its source-bound
        // configuration there so commands in the nested AppHost must discover an ancestor.
        await _auto.RunCommandAsync("mkdir AppHost && mv apphost.cs apphost.run.json AppHost", _counter);
        await _auto.RunCommandAsync("aspire config set appHost.path AppHost/apphost.cs", _counter);
    }
 
    public async Task CreateProjectWithConsentAsync(bool accept)
    {
        await _auto.TypeAsync(NewCommand);
        await _auto.EnterAsync();
        var consentPrompt = new CellPatternSearcher().Find("Use configuration from this source?");
        await _auto.WaitUntilAsync(snapshot => consentPrompt.Search(snapshot).Count > 0,
            timeout: TimeSpan.FromMinutes(2), description: "waiting for configuration source consent");
        if (!accept)
        {
            await _auto.DownAsync();
        }
        await _auto.EnterAsync();
        await _auto.WaitForSuccessPromptAsync(_counter, timeout: TimeSpan.FromMinutes(3));
        await ConfigureProjectAsync();
    }
 
    public async Task AssertConsentResultAsync(bool accepted)
    {
        if (accepted)
        {
            await AssertSourceAppliedAsync("original");
        }
        else
        {
            var configuration = await ReadConfigurationAsync();
            Assert.Null(configuration["configSource"]);
            Assert.Equal("""{"custom":"keep"}""", configuration["docs"]!.ToJsonString());
            await AssertSourceNotAcquiredAsync();
        }
    }
 
    public async Task AssertNonInteractiveConsentRequiredAsync()
    {
        await _auto.RunCommandAsync(
            $"{NewCommand} --non-interactive > \"$ASPIRE_E2E_WORKSPACE/consent.log\" 2>&1; " +
            $"result=$?; cat \"$ASPIRE_E2E_WORKSPACE/consent.log\"; test \"$result\" -eq {CliExitCodes.InvalidCommand}",
            _counter, timeout: TimeSpan.FromMinutes(3));
        var consentOutput = await File.ReadAllTextAsync(Path.Combine(_root, "consent.log"), _cancellationToken);
        Assert.Contains("Specify --use-config-source true to accept it or --use-config-source false",
            Regex.Replace(consentOutput, @"\s+", " "));
        Assert.False(Directory.Exists(Path.Combine(_root, "SourceApp")));
        await AssertSourceNotAcquiredAsync();
    }
 
    public async Task CreateProjectWithGitAuthenticationAsync()
    {
        await ConfigureGitAuthenticationAsync();
        await _auto.TypeAsync($"{NewCommand} --use-config-source true");
        await _auto.EnterAsync();
        var authenticationPrompt = new CellPatternSearcher().Find("Configuration source Git authentication code:");
        await _auto.WaitUntilAsync(snapshot => authenticationPrompt.Search(snapshot).Count > 0,
            timeout: TimeSpan.FromMinutes(2), description: "waiting for the Git authentication prompt without a trailing newline");
        await _auto.TypeAsync("allow-source");
        await _auto.EnterAsync();
        var authenticated = new CellPatternSearcher().Find("Configuration source Git authentication accepted.");
        await _auto.WaitUntilAsync(snapshot => authenticated.Search(snapshot).Count > 0,
            timeout: TimeSpan.FromSeconds(30), description: "waiting for Git to acknowledge terminal input");
        await _auto.WaitForSuccessPromptAsync(_counter, timeout: TimeSpan.FromMinutes(3));
        Assert.Equal("accepted", await File.ReadAllTextAsync(Path.Combine(_root, "git-authenticated.txt"), _cancellationToken));
        await ConfigureProjectAsync();
    }
 
    public async Task<JsonObject> AssertSourceAppliedAsync(string version)
    {
        var configuration = await ReadConfigurationAsync();
        AssertSourceValues(configuration, version, await ReadSourceVersionAsync());
        Assert.Equal(_repository, configuration["configSource"]!["repository"]!.GetValue<string>());
        Assert.Equal("teams/platform.json", configuration["configSource"]!["path"]!.GetValue<string>());
        Assert.Equal("main", configuration["configSource"]!["ref"]!.GetValue<string>());
        return configuration;
    }
 
    public async Task UpdateSourceAsync()
    {
        var previousVersion = await ReadSourceVersionAsync();
        await WriteSourceAsync("updated");
        await CommitSourceAsync();
        Assert.NotEqual(previousVersion, await ReadSourceVersionAsync());
    }
 
    public async Task UseAppHostDirectoryAsync()
    {
        var configuration = await ReadConfigurationAsync();
        var appHostPath = configuration["appHost"]!["path"]!.GetValue<string>();
        var directory = Path.GetDirectoryName(appHostPath)!.Replace('\\', '/');
        Assert.NotEmpty(directory);
        await _auto.RunCommandAsync($"cd \"{directory}\"", _counter);
        await AssertNoLocalConfigurationAsync();
    }
 
    public Task AssertNoLocalConfigurationAsync()
        => _auto.RunCommandAsync("test ! -f aspire.config.json", _counter);
 
    public async Task RunAndAssertUpdateNotificationAsync()
    {
        // Creation records a fresh successful check. Expire only this isolated source's
        // observation, then wait for the new blob version before cancelling the command.
        // This avoids both the 24-hour cadence and a race with command-exit cancellation.
        await _auto.RunCommandAsync("dotnet run \"$ASPIRE_E2E_WORKSPACE/check-source.cs\" -- expire", _counter);
        await _auto.RunCommandAsync(
            "aspire run --non-interactive > \"$ASPIRE_E2E_WORKSPACE/run.log\" 2>&1 </dev/null & source_run_pid=$!", _counter);
        await _auto.RunCommandAsync("dotnet run \"$ASPIRE_E2E_WORKSPACE/check-source.cs\" -- wait \"$source_run_pid\"", _counter,
            timeout: TimeSpan.FromMinutes(3));
        await _auto.RunCommandAsync("kill -INT \"$source_run_pid\"; timeout 120 tail --pid=\"$source_run_pid\" -f /dev/null; wait \"$source_run_pid\"", _counter,
            timeout: TimeSpan.FromMinutes(3));
        await _auto.RunCommandAsync("cat \"$ASPIRE_E2E_WORKSPACE/run.log\"", _counter);
        Assert.Contains("A new version of the configuration source is available.",
            await File.ReadAllTextAsync(Path.Combine(_root, "run.log"), _cancellationToken));
    }
 
    public async Task RefreshConfigurationAsync(bool expectChanges)
    {
        await _auto.RunCommandAsync(
            "set -o pipefail; aspire update --config-source --yes --non-interactive 2>&1 | tee \"$ASPIRE_E2E_WORKSPACE/update.log\"",
            _counter, timeout: TimeSpan.FromMinutes(2));
        var message = expectChanges ? "configuration updated" : "configuration already up to date";
        var updateOutput = await File.ReadAllTextAsync(Path.Combine(_root, "update.log"), _cancellationToken);
        Assert.Contains(message, Regex.Replace(updateOutput, @"\s+", " "));
    }
 
    public async Task AssertConfigurationUpdatedAsync(JsonObject original)
    {
        var actual = await ReadConfigurationAsync();
        var version = await ReadSourceVersionAsync();
        AssertSourceValues(actual, "updated", version);
        original["docs"]!["llmsTxtUrl"] = actual["docs"]!["llmsTxtUrl"]!.DeepClone();
        original["docs"]!["api"]!["sitemapUrl"] = actual["docs"]!["api"]!["sitemapUrl"]!.DeepClone();
        original["configSource"]!["appliedVersion"] = version;
        Assert.Equal(original.ToJsonString(), actual.ToJsonString());
    }
 
    public async Task AssertConfigurationUnchangedAsync(JsonObject original)
    {
        var actual = await ReadConfigurationAsync();
        Assert.Equal(original.ToJsonString(), actual.ToJsonString());
    }
 
    private async Task InitializeRepositoryAsync()
    {
        await _auto.RunCommandAsync("aspire config set features.updateNotificationsEnabled false -g", _counter);
        // The bind-mounted directory belongs to the host runner, not the container's root user.
        await _auto.RunCommandAsync("git config --global --add safe.directory \"$ASPIRE_E2E_WORKSPACE/configuration source\"", _counter);
        await _auto.RunCommandAsync("git -C 'configuration source' init --initial-branch=main", _counter);
        await _auto.RunCommandAsync("git -C 'configuration source' config user.name 'Aspire E2E'", _counter);
        await _auto.RunCommandAsync("git -C 'configuration source' config user.email aspire-e2e@example.com", _counter);
        await CommitSourceAsync();
        await _auto.RunCommandAsync(
            "export ASPIRE_INIT_SOURCE_URL=\"$ASPIRE_E2E_WORKSPACE/configuration source\" " +
            "ASPIRE_INIT_SOURCE_PATH=teams/platform.json ASPIRE_INIT_SOURCE_REF=main", _counter);
    }
 
    private async Task ConfigureProjectAsync()
    {
        await _auto.RunCommandAsync("cd SourceApp", _counter);
        await _auto.RunCommandAsync("aspire config set docs.custom keep", _counter);
    }
 
    private Task AssertSourceNotAcquiredAsync()
        => _auto.RunCommandAsync("test ! -d \"$HOME/.aspire/configsources\"", _counter);
 
    private async Task<JsonObject> ReadConfigurationAsync()
    {
        // Source materialization creates an owner-only file inside the container. Export its
        // contents without changing those permissions so the host can assert the saved values.
        await _auto.RunCommandAsync(
            $"cat \"$ASPIRE_E2E_WORKSPACE/{_configurationRelativePath}\" > \"$ASPIRE_E2E_WORKSPACE/actual-config.json\"", _counter);
        return JsonNode.Parse(await File.ReadAllTextAsync(Path.Combine(_root, "actual-config.json"), _cancellationToken))!.AsObject();
    }
 
    private async Task<string> ReadSourceVersionAsync()
        => (await File.ReadAllTextAsync(Path.Combine(_root, "source-version.txt"), _cancellationToken)).Trim();
 
    private Task CommitSourceAsync()
        => _auto.RunCommandAsync(
            "git -C \"$ASPIRE_E2E_WORKSPACE/configuration source\" add teams/platform.json && " +
            "git -C \"$ASPIRE_E2E_WORKSPACE/configuration source\" -c commit.gpgsign=false commit -m 'Update configuration' && " +
            "git -C \"$ASPIRE_E2E_WORKSPACE/configuration source\" rev-parse HEAD:teams/platform.json > \"$ASPIRE_E2E_WORKSPACE/source-version.txt\"", _counter);
 
    private async Task WriteSourceAsync(string version)
    {
        var directory = Directory.CreateDirectory(Path.Combine(_root, "configuration source", "teams"));
        await File.WriteAllTextAsync(Path.Combine(directory.FullName, "platform.json"), $$"""
            {
              "docs": {
                "llmsTxtUrl": "https://docs.example/{{version}}/llms.txt",
                "api": {
                  "sitemapUrl": "https://docs.example/{{version}}/sitemap.xml"
                }
              }
            }
            """, _cancellationToken);
    }
 
    private async Task ConfigureGitAuthenticationAsync()
    {
        // Substitute only SSH authentication, not Git acquisition. The launcher challenges the
        // terminal, then serves the local repository with real git-upload-pack. No network or
        // real credentials are needed, and stdout remains exclusively Git's wire protocol.
        await File.WriteAllTextAsync(Path.Combine(_root, "ssh-probe.cs"), SshProbeProgram, _cancellationToken);
        await _auto.RunCommandAsync(
            "dotnet publish \"$ASPIRE_E2E_WORKSPACE/ssh-probe.cs\" -p:PublishAot=false --output \"$ASPIRE_E2E_WORKSPACE/ssh-probe\"",
            _counter, timeout: TimeSpan.FromMinutes(2));
        // Skip Git's SSH-variant discovery so the launcher is invoked only for the fetch.
        await _auto.RunCommandAsync(
            "export GIT_SSH_VARIANT=ssh GIT_SSH_COMMAND=\"dotnet '$ASPIRE_E2E_WORKSPACE/ssh-probe/ssh-probe.dll'\" " +
            $"ASPIRE_INIT_SOURCE_URL={SshRepository}", _counter);
        _repository = SshRepository;
    }
 
    private static void AssertSourceValues(JsonObject config, string version, string appliedVersion)
    {
        Assert.Equal($"https://docs.example/{version}/llms.txt", config["docs"]!["llmsTxtUrl"]!.GetValue<string>());
        Assert.Equal($"https://docs.example/{version}/sitemap.xml", config["docs"]!["api"]!["sitemapUrl"]!.GetValue<string>());
        Assert.Equal("keep", config["docs"]!["custom"]!.GetValue<string>());
        Assert.Equal(appliedVersion, config["configSource"]!["appliedVersion"]!.GetValue<string>());
    }
 
    private const string SourceCheckProgram = """
        using System.Diagnostics;
        using System.Globalization;
        using System.Text.Json.Nodes;
 
        var home = Environment.GetEnvironmentVariable("ASPIRE_HOME")
            ?? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".aspire");
        var check = Directory.EnumerateDirectories(Path.Combine(home, "configsources"))
            .Select(directory => Path.Combine(directory, "check.json"))
            .Where(File.Exists)
            .Single();
        if (args[0] == "expire")
        {
            var metadata = JsonNode.Parse(await File.ReadAllTextAsync(check))!.AsObject();
            metadata["checkedAt"] = "2000-01-01T00:00:00+00:00";
            await File.WriteAllTextAsync(check, metadata.ToJsonString());
            return;
        }
 
        if (args[0] != "wait")
        {
            throw new ArgumentException("Expected expire or wait.");
        }
 
        var workspace = Environment.GetEnvironmentVariable("ASPIRE_E2E_WORKSPACE")
            ?? throw new InvalidOperationException("ASPIRE_E2E_WORKSPACE is not set.");
        var expected = (await File.ReadAllTextAsync(Path.Combine(workspace, "source-version.txt"))).Trim();
        using var process = Process.GetProcessById(int.Parse(args[1], CultureInfo.InvariantCulture));
        var stopwatch = Stopwatch.StartNew();
        while (stopwatch.Elapsed < TimeSpan.FromMinutes(2))
        {
            process.Refresh();
            if (process.HasExited)
            {
                throw new InvalidOperationException("The AppHost command exited before completing its configuration source check.");
            }
 
            var metadata = JsonNode.Parse(await File.ReadAllTextAsync(check))!.AsObject();
            if (metadata["version"]!.GetValue<string>() == expected)
            {
                return;
            }
 
            await Task.Delay(100);
        }
 
        throw new TimeoutException("The AppHost command did not complete its configuration source check.");
        """;
 
    private const string SshProbeProgram = """
        using System.Diagnostics;
 
        var workspace = Environment.GetEnvironmentVariable("ASPIRE_E2E_WORKSPACE")
            ?? throw new InvalidOperationException("ASPIRE_E2E_WORKSPACE is not set.");
        if (Environment.GetEnvironmentVariable("GIT_TERMINAL_PROMPT") != "1")
        {
            throw new InvalidOperationException("Git terminal authentication was not enabled.");
        }
 
        var authenticationPath = Path.Combine(workspace, "git-authenticated.txt");
        // Reference resolution and fetch open separate SSH connections. Reuse the accepted
        // authentication like an SSH agent so only the first connection challenges the user.
        if (!File.Exists(authenticationPath))
        {
            Console.Error.Write("Configuration source Git authentication code: ");
            Console.Error.Flush();
            // SSH's stdin carries Git protocol traffic. Like an SSH authentication prompt,
            // read the challenge response from the controlling terminal instead.
            using (var terminal = new StreamReader(new FileStream("/dev/tty", FileMode.Open, FileAccess.Read, FileShare.ReadWrite)))
            {
                if (await terminal.ReadLineAsync() != "allow-source")
                {
                    Console.Error.WriteLine("Configuration source Git authentication failed.");
                    return 1;
                }
            }
 
            await File.WriteAllTextAsync(authenticationPath, "accepted");
            Console.Error.WriteLine("Configuration source Git authentication accepted.");
        }
 
        var startInfo = new ProcessStartInfo("git")
        {
            UseShellExecute = false
        };
        startInfo.ArgumentList.Add("upload-pack");
        startInfo.ArgumentList.Add(Path.Combine(workspace, "configuration source"));
        // A real SSH server does not inherit the client's repository environment.
        foreach (var name in new[] { "GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GIT_OBJECT_DIRECTORY", "GIT_ALTERNATE_OBJECT_DIRECTORIES" })
        {
            startInfo.Environment.Remove(name);
        }
 
        using var process = Process.Start(startInfo)
            ?? throw new InvalidOperationException("Could not start git-upload-pack.");
        await process.WaitForExitAsync();
        return process.ExitCode;
        """;
}