neon-sprawl/server/NeonSprawl.Server.Tests/Game/PositionState/PostgresDockerHelper.cs

200 lines
6.9 KiB
C#

using System.Diagnostics;
namespace NeonSprawl.Server.Tests.Game.PositionState;
/// <summary>
/// When not in CI, tries TCP to Postgres; if unreachable, runs <c>docker compose up -d</c> from the repo root.
/// Tracks whether compose was started so teardown can run <c>docker compose down</c>.
/// </summary>
/// <remarks>
/// All public entry points avoid <c>GetAwaiter().GetResult()</c> on the xUnit synchronization context;
/// <see cref="EnsurePostgresOrStartDockerBlocking"/> defers to the thread pool to prevent Rider/VS deadlocks.
/// </remarks>
internal static class PostgresDockerHelper
{
private static readonly Lock StateLock = new();
private static Task? _ensureTask;
private static bool _composeStartedByUs;
private static string? _repoRoot;
/// <summary>Async path for <see cref="PostgresIntegrationHarness.InitializeAsync"/> (no deadlock).</summary>
internal static Task EnsurePostgresOrStartDockerAsync(string? connectionString)
{
if (string.IsNullOrWhiteSpace(connectionString))
{
return Task.CompletedTask;
}
lock (StateLock)
{
_ensureTask ??= EnsureCoreAsync(connectionString.Trim());
return _ensureTask;
}
}
/// <summary>
/// Used from <see cref="PostgresWebApplicationFactory.ConfigureWebHost"/> (sync only). Runs work on the thread pool
/// so <c>Npgsql</c> / <c>Task</c> continuations do not post back to a blocked xUnit context.
/// </summary>
internal static void EnsurePostgresOrStartDockerBlocking(string? connectionString)
{
if (string.IsNullOrWhiteSpace(connectionString))
{
return;
}
Task.Run(() => EnsurePostgresOrStartDockerAsync(connectionString).GetAwaiter().GetResult())
.GetAwaiter()
.GetResult();
}
internal static async Task TearDownIfWeStartedAsync()
{
string? root;
bool weStarted;
lock (StateLock)
{
weStarted = _composeStartedByUs;
root = _repoRoot;
_composeStartedByUs = false;
_repoRoot = null;
_ensureTask = null;
}
if (!weStarted || string.IsNullOrEmpty(root))
{
return;
}
await RunDockerComposeAsync(root, "compose down", CancellationToken.None).ConfigureAwait(false);
}
private static async Task EnsureCoreAsync(string connectionString)
{
if (IsCiEnvironment())
{
await TryConnectWithTimeoutAsync(connectionString, TimeSpan.FromSeconds(30), CancellationToken.None)
.ConfigureAwait(false);
return;
}
if (await TryConnectOnceAsync(connectionString, CancellationToken.None).ConfigureAwait(false))
{
return;
}
var root = FindDockerComposeRoot()
?? throw new InvalidOperationException(
"Postgres is not reachable and docker-compose.yml was not found by walking up from the test output directory.");
await RunDockerComposeAsync(root, "compose up -d", CancellationToken.None).ConfigureAwait(false);
await TryConnectWithTimeoutAsync(connectionString, TimeSpan.FromSeconds(60), CancellationToken.None)
.ConfigureAwait(false);
lock (StateLock)
{
_composeStartedByUs = true;
_repoRoot = root;
}
}
private static bool IsCiEnvironment() =>
string.Equals(Environment.GetEnvironmentVariable("CI"), "true", StringComparison.OrdinalIgnoreCase) ||
string.Equals(Environment.GetEnvironmentVariable("GITHUB_ACTIONS"), "true", StringComparison.OrdinalIgnoreCase) ||
string.Equals(Environment.GetEnvironmentVariable("GITLAB_CI"), "true", StringComparison.OrdinalIgnoreCase) ||
string.Equals(Environment.GetEnvironmentVariable("TF_BUILD"), "True", StringComparison.OrdinalIgnoreCase);
private static string? FindDockerComposeRoot()
{
var dir = new DirectoryInfo(AppContext.BaseDirectory);
while (dir is not null)
{
var yml = Path.Combine(dir.FullName, "docker-compose.yml");
if (File.Exists(yml))
{
try
{
var text = File.ReadAllText(yml);
if (text.Contains("neon-sprawl-postgres", StringComparison.Ordinal) ||
text.Contains("postgres:", StringComparison.OrdinalIgnoreCase))
{
return dir.FullName;
}
}
catch (IOException)
{
// keep walking
}
}
dir = dir.Parent;
}
return null;
}
private static async Task RunDockerComposeAsync(string workingDirectory, string arguments, CancellationToken ct)
{
var psi = new ProcessStartInfo
{
FileName = "docker",
Arguments = arguments,
WorkingDirectory = workingDirectory,
UseShellExecute = false,
CreateNoWindow = true,
RedirectStandardOutput = true,
RedirectStandardError = true,
};
using var proc = new Process { StartInfo = psi };
if (!proc.Start())
{
throw new InvalidOperationException("Failed to start `docker` — is Docker installed and on PATH?");
}
await proc.WaitForExitAsync(ct).ConfigureAwait(false);
if (proc.ExitCode != 0)
{
var err = await proc.StandardError.ReadToEndAsync(ct).ConfigureAwait(false);
var @out = await proc.StandardOutput.ReadToEndAsync(ct).ConfigureAwait(false);
throw new InvalidOperationException(
$"`docker {arguments}` failed (exit {proc.ExitCode}). stderr: {err}. stdout: {@out}");
}
}
private static async Task TryConnectWithTimeoutAsync(
string connectionString,
TimeSpan overall,
CancellationToken ct)
{
var deadline = DateTime.UtcNow + overall;
while (DateTime.UtcNow < deadline)
{
if (await TryConnectOnceAsync(connectionString, ct).ConfigureAwait(false))
{
return;
}
await Task.Delay(TimeSpan.FromMilliseconds(500), ct).ConfigureAwait(false);
}
throw new InvalidOperationException(
$"Could not open a Postgres connection within {overall.TotalSeconds:0}s. Check the connection string and that the server is up.");
}
private static async Task<bool> TryConnectOnceAsync(string connectionString, CancellationToken ct)
{
try
{
await using var conn = new NpgsqlConnection(connectionString);
await conn.OpenAsync(ct).ConfigureAwait(false);
return true;
}
catch (NpgsqlException)
{
return false;
}
}
}