205 lines
6.9 KiB
C#
205 lines
6.9 KiB
C#
using System.Diagnostics;
|
|
using System.Threading;
|
|
using Npgsql;
|
|
|
|
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();
|
|
if (root is null)
|
|
{
|
|
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;
|
|
}
|
|
}
|
|
}
|