From 862e074eac0ade38ccee0ecd9e8485b66e0ecfd1 Mon Sep 17 00:00:00 2001 From: Louis Zanella Date: Wed, 16 Sep 2026 15:21:55 -0400 Subject: [PATCH 1/7] Docker.DotNet -> Testcontainers migration --- Directory.Packages.props | 3 +- NGitLab.Tests/Docker/GitLabDockerContainer.cs | 261 +++++++----------- NGitLab.Tests/NGitLab.Tests.csproj | 3 +- 3 files changed, 99 insertions(+), 168 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 01f29077..4632b817 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -5,7 +5,7 @@ - + @@ -22,6 +22,7 @@ + diff --git a/NGitLab.Tests/Docker/GitLabDockerContainer.cs b/NGitLab.Tests/Docker/GitLabDockerContainer.cs index cd3a455f..f10ae247 100644 --- a/NGitLab.Tests/Docker/GitLabDockerContainer.cs +++ b/NGitLab.Tests/Docker/GitLabDockerContainer.cs @@ -1,7 +1,6 @@ #pragma warning disable MA0004 #pragma warning disable MA0006 using System; -using System.Collections.Generic; using System.Diagnostics; using System.Globalization; using System.IO; @@ -13,6 +12,8 @@ using System.Threading.Tasks; using Docker.DotNet; using Docker.DotNet.Models; +using DotNet.Testcontainers.Builders; +using DotNet.Testcontainers.Containers; using NGitLab.Models; using NUnit.Framework; using Polly; @@ -38,6 +39,13 @@ public class GitLabDockerContainer private static readonly SemaphoreSlim s_setupLock = new(initialCount: 1, maxCount: 1); private static GitLabDockerContainer s_instance; + /// + /// Set only when the container was spawned locally via Testcontainers. + /// On CI, GitLab already runs as a pre-existing service container, so this stays null + /// and credential generation falls back to a raw Docker Engine API exec call. + /// + private IContainer _container; + public string Host { get; private set; } = "localhost"; public int HttpPort { get; private set; } = 48624; @@ -138,148 +146,59 @@ private static async Task ValidateDockerIsEnabled(DockerClient client) private async Task SpawnDockerContainerAsync() { Console.WriteLine($"Executing tests locally. Spawning GitLab docker image version '{LocalGitLabDockerVersion}'"); - using var httpClient = new HttpClient(); - - // Spawn the container - // https://docs.gitlab.com/omnibus/settings/configuration.html - using var conf = new DockerClientConfiguration(new Uri(OperatingSystem.IsWindows() ? "npipe://./pipe/docker_engine" : "unix:///var/run/docker.sock")); - using var client = conf.CreateClient(); - await ValidateDockerIsEnabled(client); - - TestContext.Progress.WriteLine("Looking up GitLab Docker containers"); - var containers = await client.Containers.ListContainersAsync(new ContainersListParameters { All = true }).ConfigureAwait(false); - var container = containers.FirstOrDefault(c => c.Names.Contains("/" + ContainerName, StringComparer.Ordinal)); - if (container != null) - { - TestContext.Progress.WriteLine("Verifying if the GitLab Docker container is using the right image"); - var inspect = await client.Containers.InspectContainerAsync(container.ID).ConfigureAwait(false); - var inspectImage = await client.Images.InspectImageAsync(ImageName + ":" + LocalGitLabDockerVersion).ConfigureAwait(false); - if (inspect.Image != inspectImage.ID) - { - TestContext.Progress.WriteLine("Ending GitLab Docker container, as it's using the wrong image"); - await client.Containers.RemoveContainerAsync(container.ID, new ContainerRemoveParameters { Force = true }).ConfigureAwait(false); - container = null; - } - } - - if (container == null) - { - // Download GitLab images - TestContext.Progress.WriteLine("Making sure the right GitLab Docker image is available locally"); - await client.Images.CreateImageAsync(new ImagesCreateParameters { FromImage = ImageName, Tag = LocalGitLabDockerVersion }, new AuthConfig(), new Progress()).ConfigureAwait(false); - - // Create the container - TestContext.Progress.WriteLine("Creating the GitLab Docker container"); - var hostConfig = new HostConfig - { - PortBindings = new Dictionary>(StringComparer.Ordinal) - { - { HttpPort.ToString(CultureInfo.InvariantCulture) + "/tcp", new List { new PortBinding { HostPort = HttpPort.ToString(CultureInfo.InvariantCulture) } } }, - }, - - // Update size of /dev/shm to to 512mb (default: 64mb) - // Avoids intermittent crashes of GitLab - ShmSize = 512 * 1024 * 1024, - }; - - // Disables non-useful features - // See https://gitlab.com/gitlab-org/omnibus-gitlab/blob/master/files/gitlab-config-template/gitlab.rb.template - string[] omnibusConfig = - [ - $"external_url 'http://localhost:{HttpPort.ToString(CultureInfo.InvariantCulture)}/'", - "gitlab_rails['gitlab_email_enabled'] = false", - "gitlab_rails['incoming_email_enabled'] = false", - "gitlab_rails['lfs_enabled'] = false", - "gitlab_rails['terraform_state_enabled'] = false", - "gitlab_rails['pages_object_store_enabled'] = false", - "gitlab_rails['usage_ping_enabled'] = false", - "gitlab_rails['registry_enabled'] = false", - "registry['enable'] = false", - "sidekiq['metrics_enabled'] = false", - "logrotate['enable'] = false", - "gitlab_pages['enable'] = false", - "gitlab_rails['gitlab_kas_enabled'] = false", - "mattermost['enable'] = false", - "alertmanager['enable'] = false", - "node_exporter['enable'] = false", - "redis_exporter['enable'] = false", - "postgres_exporter['enable'] = false", - "pgbouncer_exporter['enable'] = false", - "gitlab_exporter['enable'] = false", - "gitlab_rails['kerberos_enabled'] = false", - "gitlab_rails['packages_enabled'] = false", - "gitlab_rails['dependency_proxy_enabled'] = false", - ]; - - var response = await client.Containers.CreateContainerAsync(new CreateContainerParameters - { - Hostname = "localhost", - Image = ImageName + ":" + LocalGitLabDockerVersion, - Name = ContainerName, - Tty = false, - HostConfig = hostConfig, - ExposedPorts = new Dictionary(StringComparer.Ordinal) - { - { HttpPort.ToString(CultureInfo.InvariantCulture) + "/tcp", default }, - }, - Env = - [ - $"GITLAB_ROOT_PASSWORD={AdminPassword}", - $"GITLAB_OMNIBUS_CONFIG={string.Join("; ", omnibusConfig)}", - ], - }).ConfigureAwait(false); - - containers = await client.Containers.ListContainersAsync(new ContainersListParameters { All = true }).ConfigureAwait(false); - container = containers.First(c => c.ID == response.ID); - } - - // Start the container - if (container.State != "running") - { - TestContext.Progress.WriteLine("Starting the GitLab Docker container"); - var started = await client.Containers.StartContainerAsync(container.ID, new ContainerStartParameters()).ConfigureAwait(false); - if (!started) - { - Assert.Fail("Cannot start the Docker container"); - } - } - // Wait for the container to be ready. - var stopwatch = Stopwatch.StartNew(); - while (true) - { - TestContext.Progress.WriteLine($@"Waiting for the GitLab Docker container to be ready ({stopwatch.Elapsed:mm\:ss})"); - var status = await client.Containers.InspectContainerAsync(container.ID); - if (!status.State.Running) - throw new InvalidOperationException($"Container '{status.ID}' is not running"); - - var healthState = status.State.Health.Status; - - // unhealthy is valid as long as the container is running as it may indicate a slow creation - if (healthState is "starting" or "unhealthy") - { - } - else if (healthState is "healthy") - { - // A healthy container doesn't mean the service is actually running. - // GitLab has lots of configuration steps that are still running when the container is healthy. - try - { - using var response = await httpClient.GetAsync(GitLabUrl).ConfigureAwait(false); - if (response.IsSuccessStatusCode) - break; - } - catch - { - } - } - else - { - throw new InvalidOperationException($"Container status '{healthState}' is not supported"); - } + // Disables non-useful features + // See https://gitlab.com/gitlab-org/omnibus-gitlab/blob/master/files/gitlab-config-template/gitlab.rb.template + string[] omnibusConfig = + [ + $"external_url 'http://localhost:{HttpPort.ToString(CultureInfo.InvariantCulture)}/'", + "gitlab_rails['gitlab_email_enabled'] = false", + "gitlab_rails['incoming_email_enabled'] = false", + "gitlab_rails['lfs_enabled'] = false", + "gitlab_rails['terraform_state_enabled'] = false", + "gitlab_rails['pages_object_store_enabled'] = false", + "gitlab_rails['usage_ping_enabled'] = false", + "gitlab_rails['registry_enabled'] = false", + "registry['enable'] = false", + "sidekiq['metrics_enabled'] = false", + "logrotate['enable'] = false", + "gitlab_pages['enable'] = false", + "gitlab_rails['gitlab_kas_enabled'] = false", + "mattermost['enable'] = false", + "alertmanager['enable'] = false", + "node_exporter['enable'] = false", + "redis_exporter['enable'] = false", + "postgres_exporter['enable'] = false", + "pgbouncer_exporter['enable'] = false", + "gitlab_exporter['enable'] = false", + "gitlab_rails['kerberos_enabled'] = false", + "gitlab_rails['packages_enabled'] = false", + "gitlab_rails['dependency_proxy_enabled'] = false", + ]; - await Task.Delay(5000); - } + // https://docs.gitlab.com/omnibus/settings/configuration.html + // GitLab reports "healthy" long before it's actually ready to serve requests, so we wait + // on the HTTP endpoint itself rather than on the container's health status. + // WithReuse keeps an existing container running across local test runs (GitLab takes + // minutes to boot); bumping LocalGitLabDockerVersion requires removing the old container + // manually (`docker rm -f NGitLabClientTests`) since reuse matching is name+config based. + _container = new ContainerBuilder() + .WithImage(ImageName + ":" + LocalGitLabDockerVersion) + .WithName(ContainerName) + .WithHostname("localhost") + .WithPortBinding(HttpPort, HttpPort) + .WithEnvironment("GITLAB_ROOT_PASSWORD", AdminPassword) + .WithEnvironment("GITLAB_OMNIBUS_CONFIG", string.Join("; ", omnibusConfig)) + .WithCreateParameterModifier(p => p.HostConfig.ShmSize = 512 * 1024 * 1024) // Default 64mb is too small and causes intermittent GitLab crashes + .WithWaitStrategy(Wait.ForUnixContainer() + .UntilHttpRequestIsSucceeded( + r => r.ForPort((ushort)HttpPort).ForPath("/"), + o => o.WithTimeout(TimeSpan.FromMinutes(10)).WithInterval(TimeSpan.FromSeconds(5)))) + .WithReuse(true) + .Build(); + + TestContext.Progress.WriteLine("Starting the GitLab Docker container (this can take several minutes on first run)"); + await _container.StartAsync().ConfigureAwait(false); TestContext.Progress.WriteLine("GitLab Docker container is ready"); } @@ -300,11 +219,6 @@ private async Task GenerateCredentialsAsync() async Task GenerateAdminToken(GitLabCredential credentials) { TestContext.Progress.WriteLine("Generating Credentials"); - - using var conf = new DockerClientConfiguration(new Uri(OperatingSystem.IsWindows() ? "npipe://./pipe/docker_engine" : "unix:///var/run/docker.sock")); - using var client = conf.CreateClient(); - await ValidateDockerIsEnabled(client).ConfigureAwait(false); - TestContext.Progress.WriteLine("Creating root token via 'gitlab-rails runner'"); // Keep only scopes the running GitLab version supports (an unknown scope makes `create!` raise). @@ -319,13 +233,7 @@ puts token.token """; var retryPolicy = Policy.Handle().WaitAndRetryAsync(20, _ => TimeSpan.FromSeconds(3)); - var token = await retryPolicy.ExecuteAsync(async () => - { - var containerId = await ResolveGitLabContainerIdAsync(client).ConfigureAwait(false); - return await RunGitLabRailsRunnerAsync(client, containerId, script).ConfigureAwait(false); - }).ConfigureAwait(false); - - credentials.AdminUserToken = token; + credentials.AdminUserToken = await retryPolicy.ExecuteAsync(() => RunGitLabRailsRunnerAsync(script)).ConfigureAwait(false); } void GenerateUserToken() @@ -382,25 +290,46 @@ private static async Task ResolveGitLabContainerIdAsync(DockerClient cli return container.ID; } - private static async Task RunGitLabRailsRunnerAsync(DockerClient client, string containerId, string script) + // When we spawned the container ourselves (local dev), Testcontainers already holds a reference to it + // and can exec into it directly. On CI, GitLab runs as a pre-existing service container we didn't create, + // so we fall back to the raw Docker Engine API to find it and exec into it. + private async Task RunGitLabRailsRunnerAsync(string script) { - var execCreateResponse = await client.Exec.ExecCreateContainerAsync(containerId, new ContainerExecCreateParameters - { - AttachStdout = true, - AttachStderr = true, - Cmd = ["gitlab-rails", "runner", script], - }).ConfigureAwait(false); - string stdout; string stderr; - using (var stream = await client.Exec.StartAndAttachContainerExecAsync(execCreateResponse.ID, tty: false).ConfigureAwait(false)) + long? exitCode; + + if (_container != null) + { + var result = await _container.ExecAsync(["gitlab-rails", "runner", script]).ConfigureAwait(false); + (stdout, stderr, exitCode) = (result.Stdout, result.Stderr, result.ExitCode); + } + else { - (stdout, stderr) = await stream.ReadOutputToEndAsync(CancellationToken.None).ConfigureAwait(false); + using var client = new DockerClientBuilder() + .WithEndpoint(new Uri(OperatingSystem.IsWindows() ? "npipe://./pipe/docker_engine" : "unix:///var/run/docker.sock")) + .Build(); + await ValidateDockerIsEnabled(client).ConfigureAwait(false); + + var containerId = await ResolveGitLabContainerIdAsync(client).ConfigureAwait(false); + var execCreateResponse = await client.Exec.CreateContainerExecAsync(containerId, new ContainerExecCreateParameters + { + AttachStdout = true, + AttachStderr = true, + Cmd = ["gitlab-rails", "runner", script], + }).ConfigureAwait(false); + + using (var stream = await client.Exec.StartContainerExecAsync(execCreateResponse.ID, new ContainerExecStartParameters { TTY = false }).ConfigureAwait(false)) + { + (stdout, stderr) = await stream.ReadOutputToEndAsync(CancellationToken.None).ConfigureAwait(false); + } + + var inspectResponse = await client.Exec.InspectContainerExecAsync(execCreateResponse.ID).ConfigureAwait(false); + exitCode = inspectResponse.ExitCode; } - var inspectResponse = await client.Exec.InspectContainerExecAsync(execCreateResponse.ID).ConfigureAwait(false); - if (inspectResponse.ExitCode != 0) - throw new InvalidOperationException($"'gitlab-rails runner' failed with exit code {inspectResponse.ExitCode}.\nStdout: {stdout}\nStderr: {stderr}"); + if (exitCode != 0) + throw new InvalidOperationException($"'gitlab-rails runner' failed with exit code {exitCode}.\nStdout: {stdout}\nStderr: {stderr}"); var token = stdout .Split('\n') diff --git a/NGitLab.Tests/NGitLab.Tests.csproj b/NGitLab.Tests/NGitLab.Tests.csproj index 4e353f38..01e77c30 100644 --- a/NGitLab.Tests/NGitLab.Tests.csproj +++ b/NGitLab.Tests/NGitLab.Tests.csproj @@ -7,7 +7,7 @@ - + @@ -18,6 +18,7 @@ + all runtime; build; native; contentfiles; analyzers From 54e9ddffc08a80dccc1e66c9993dbce21d21e38f Mon Sep 17 00:00:00 2001 From: Louis Zanella Date: Thu, 17 Sep 2026 08:36:50 -0400 Subject: [PATCH 2/7] tweaks --- NGitLab.Tests/Docker/GitLabDockerContainer.cs | 60 ++++++++++++++----- 1 file changed, 44 insertions(+), 16 deletions(-) diff --git a/NGitLab.Tests/Docker/GitLabDockerContainer.cs b/NGitLab.Tests/Docker/GitLabDockerContainer.cs index f10ae247..8920f7ce 100644 --- a/NGitLab.Tests/Docker/GitLabDockerContainer.cs +++ b/NGitLab.Tests/Docker/GitLabDockerContainer.cs @@ -1,5 +1,3 @@ -#pragma warning disable MA0004 -#pragma warning disable MA0006 using System; using System.Diagnostics; using System.Globalization; @@ -14,6 +12,7 @@ using Docker.DotNet.Models; using DotNet.Testcontainers.Builders; using DotNet.Testcontainers.Containers; +using Microsoft.Extensions.Logging; using NGitLab.Models; using NUnit.Framework; using Polly; @@ -44,7 +43,7 @@ public class GitLabDockerContainer /// On CI, GitLab already runs as a pre-existing service container, so this stays null /// and credential generation falls back to a raw Docker Engine API exec call. /// - private IContainer _container; + private IContainer _localContainer; public string Host { get; private set; } = "localhost"; @@ -111,7 +110,7 @@ private async Task SetupAsync() } else { - await SpawnDockerContainerAsync().ConfigureAwait(false); + await SpawnLocalDockerContainerAsync().ConfigureAwait(false); } LoadCredentials(); @@ -126,7 +125,7 @@ private async Task SetupAsync() PersistCredentialsAsync(); } - private static async Task ValidateDockerIsEnabled(DockerClient client) + private static async Task ValidateCiDockerIsEnabled(DockerClient client) { try { @@ -143,7 +142,7 @@ private static async Task ValidateDockerIsEnabled(DockerClient client) } } - private async Task SpawnDockerContainerAsync() + private async Task SpawnLocalDockerContainerAsync() { Console.WriteLine($"Executing tests locally. Spawning GitLab docker image version '{LocalGitLabDockerVersion}'"); @@ -182,8 +181,7 @@ private async Task SpawnDockerContainerAsync() // WithReuse keeps an existing container running across local test runs (GitLab takes // minutes to boot); bumping LocalGitLabDockerVersion requires removing the old container // manually (`docker rm -f NGitLabClientTests`) since reuse matching is name+config based. - _container = new ContainerBuilder() - .WithImage(ImageName + ":" + LocalGitLabDockerVersion) + _localContainer = new ContainerBuilder(ImageName + ":" + LocalGitLabDockerVersion) .WithName(ContainerName) .WithHostname("localhost") .WithPortBinding(HttpPort, HttpPort) @@ -195,10 +193,11 @@ private async Task SpawnDockerContainerAsync() r => r.ForPort((ushort)HttpPort).ForPath("/"), o => o.WithTimeout(TimeSpan.FromMinutes(10)).WithInterval(TimeSpan.FromSeconds(5)))) .WithReuse(true) + .WithLogger(TestProgressLogger.Instance) .Build(); TestContext.Progress.WriteLine("Starting the GitLab Docker container (this can take several minutes on first run)"); - await _container.StartAsync().ConfigureAwait(false); + await _localContainer.StartAsync().ConfigureAwait(false); TestContext.Progress.WriteLine("GitLab Docker container is ready"); } @@ -269,7 +268,7 @@ void GenerateUserToken() { UserId = user.Id, Name = "common_user", - Scopes = new[] { "api" }, + Scopes = ["api"], ExpiresAt = DateTime.UtcNow.AddDays(7), })); @@ -277,7 +276,7 @@ void GenerateUserToken() } } - private static async Task ResolveGitLabContainerIdAsync(DockerClient client) + private static async Task ResolveCiGitLabContainerIdAsync(DockerClient client) { var containers = await client.Containers.ListContainersAsync(new ContainersListParameters { All = true }).ConfigureAwait(false); @@ -299,9 +298,9 @@ private async Task RunGitLabRailsRunnerAsync(string script) string stderr; long? exitCode; - if (_container != null) + if (_localContainer is not null) { - var result = await _container.ExecAsync(["gitlab-rails", "runner", script]).ConfigureAwait(false); + var result = await _localContainer.ExecAsync(["gitlab-rails", "runner", script]).ConfigureAwait(false); (stdout, stderr, exitCode) = (result.Stdout, result.Stderr, result.ExitCode); } else @@ -309,9 +308,9 @@ private async Task RunGitLabRailsRunnerAsync(string script) using var client = new DockerClientBuilder() .WithEndpoint(new Uri(OperatingSystem.IsWindows() ? "npipe://./pipe/docker_engine" : "unix:///var/run/docker.sock")) .Build(); - await ValidateDockerIsEnabled(client).ConfigureAwait(false); + await ValidateCiDockerIsEnabled(client).ConfigureAwait(false); - var containerId = await ResolveGitLabContainerIdAsync(client).ConfigureAwait(false); + var containerId = await ResolveCiGitLabContainerIdAsync(client).ConfigureAwait(false); var execCreateResponse = await client.Exec.CreateContainerExecAsync(containerId, new ContainerExecCreateParameters { AttachStdout = true, @@ -397,10 +396,39 @@ private async Task WaitForCiGitLabInstance() { } - await Task.Delay(1000); + await Task.Delay(1000).ConfigureAwait(false); } s_creationErrorMessage = "GitLab is not well configured in CI"; Assert.Fail(s_creationErrorMessage); } + + // Testcontainers defaults to ConsoleLogger, but NUnit buffers Console output until the test + // finishes, so it doesn't give live feedback while the container is starting. Forwarding to + // TestContext.Progress instead surfaces Testcontainers' own lifecycle messages as they happen. + private sealed class TestProgressLogger : ILogger + { + public static readonly TestProgressLogger Instance = new(); + + public bool IsEnabled(LogLevel logLevel) => logLevel >= LogLevel.Information; + + public IDisposable BeginScope(TState state) => NullScope.Instance; + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception exception, Func formatter) + { + if (IsEnabled(logLevel)) + { + TestContext.Progress.WriteLine(formatter(state, exception)); + } + } + + private sealed class NullScope : IDisposable + { + public static readonly NullScope Instance = new(); + + public void Dispose() + { + } + } + } } From 560a07c2e1f7c6b226cd223219ffa5e3cd6b5173 Mon Sep 17 00:00:00 2001 From: Louis Zanella Date: Thu, 17 Sep 2026 10:49:21 -0400 Subject: [PATCH 3/7] further cleanup --- NGitLab.Tests/Docker/GitLabDockerContainer.cs | 38 +++++++++---------- NGitLab.Tests/Docker/GitLabTestContext.cs | 2 +- 2 files changed, 20 insertions(+), 20 deletions(-) diff --git a/NGitLab.Tests/Docker/GitLabDockerContainer.cs b/NGitLab.Tests/Docker/GitLabDockerContainer.cs index 8920f7ce..1841d006 100644 --- a/NGitLab.Tests/Docker/GitLabDockerContainer.cs +++ b/NGitLab.Tests/Docker/GitLabDockerContainer.cs @@ -21,18 +21,18 @@ namespace NGitLab.Tests.Docker; public class GitLabDockerContainer { - public const string ContainerName = "NGitLabClientTests"; + public const string LocalContainerName = "NGitLabClientTests"; public const string ImageName = "gitlab/gitlab-ee"; /// - /// GitLab docker image version to spawn. + /// GitLab Docker image version to spawn. /// Used only on local environment (CI should already have a running GitLab instance from its services) /// /// /// Keep in sync with .github/workflows/ci.yml, use the lowest supported version /// List of available versions: https://hub.docker.com/r/gitlab/gitlab-ee/tags/ /// - private const string LocalGitLabDockerVersion = "19.3.1-ee.0"; + private const string LocalGitLabDockerVersion = "18.1.6-ee.0"; private static string s_creationErrorMessage; private static readonly SemaphoreSlim s_setupLock = new(initialCount: 1, maxCount: 1); @@ -45,9 +45,9 @@ public class GitLabDockerContainer /// private IContainer _localContainer; - public string Host { get; private set; } = "localhost"; + public string Host { get; } = "localhost"; - public int HttpPort { get; private set; } = 48624; + public int HttpPort { get; } = 48624; public string AdminUserName { get; } = "root"; @@ -63,13 +63,11 @@ public static string AdminPassword } } - public string LicenseFile { get; set; } - public Uri GitLabUrl => new("http://" + Host + ":" + HttpPort.ToString(CultureInfo.InvariantCulture)); public GitLabCredential Credentials { get; set; } - public static async Task GetOrCreateInstance() + public static async Task GetOrCreateInstanceAsync() { await s_setupLock.WaitAsync().ConfigureAwait(false); try @@ -106,7 +104,7 @@ private async Task SetupAsync() { if (GitLabTestContext.IsContinuousIntegration()) { - await WaitForCiGitLabInstance().ConfigureAwait(false); + await WaitForCiGitLabInstanceAsync().ConfigureAwait(false); } else { @@ -122,7 +120,7 @@ private async Task SetupAsync() } await GenerateCredentialsAsync().ConfigureAwait(false); - PersistCredentialsAsync(); + PersistCredentials(); } private static async Task ValidateCiDockerIsEnabled(DockerClient client) @@ -182,7 +180,7 @@ private async Task SpawnLocalDockerContainerAsync() // minutes to boot); bumping LocalGitLabDockerVersion requires removing the old container // manually (`docker rm -f NGitLabClientTests`) since reuse matching is name+config based. _localContainer = new ContainerBuilder(ImageName + ":" + LocalGitLabDockerVersion) - .WithName(ContainerName) + .WithName(LocalContainerName) .WithHostname("localhost") .WithPortBinding(HttpPort, HttpPort) .WithEnvironment("GITLAB_ROOT_PASSWORD", AdminPassword) @@ -207,7 +205,7 @@ private async Task GenerateCredentialsAsync() Console.WriteLine("Requesting credentials from GitLab instance"); var credentials = new GitLabCredential(); - await GenerateAdminToken(credentials).ConfigureAwait(false); + await GenerateAdminToken().ConfigureAwait(false); if (credentials.AdminUserToken != null) { GenerateUserToken(); @@ -215,16 +213,16 @@ private async Task GenerateCredentialsAsync() Credentials = credentials; - async Task GenerateAdminToken(GitLabCredential credentials) + async Task GenerateAdminToken() { TestContext.Progress.WriteLine("Generating Credentials"); TestContext.Progress.WriteLine("Creating root token via 'gitlab-rails runner'"); // Keep only scopes the running GitLab version supports (an unknown scope makes `create!` raise). - const string script = """ + var script = $""" desired_scopes = %w[api read_user read_api read_repository write_repository sudo admin_mode create_runner manage_runner k8s_proxy] available_scopes = Gitlab::Auth.all_available_scopes.map(&:to_s) - token = User.find_by_username!('root').personal_access_tokens.create!( + token = User.find_by_username!('{AdminUserName}').personal_access_tokens.create!( name: 'NGitLabClientTest', scopes: (desired_scopes & available_scopes), expires_at: 1.year.from_now) @@ -280,8 +278,10 @@ private static async Task ResolveCiGitLabContainerIdAsync(DockerClient c { var containers = await client.Containers.ListContainersAsync(new ContainersListParameters { All = true }).ConfigureAwait(false); - var container = containers.FirstOrDefault(c => c.Names.Contains("/" + ContainerName, StringComparer.Ordinal)) - ?? containers.FirstOrDefault(c => c.Image.StartsWith(ImageName, StringComparison.Ordinal)); + // On CI, GitLab runs as a pre-existing service container, which isn't named LocalContainerName + // (that name is only ever assigned by our own Testcontainers-managed local container), so + // we locate it by image instead. + var container = containers.FirstOrDefault(c => c.Image.StartsWith(ImageName, StringComparison.Ordinal)); if (container == null) throw new InvalidOperationException($"Cannot find a running Docker container for image '{ImageName}' to generate credentials from."); @@ -341,7 +341,7 @@ private async Task RunGitLabRailsRunnerAsync(string script) return token; } - private void PersistCredentialsAsync() + private void PersistCredentials() { var path = GetCredentialsFilePath(); Directory.CreateDirectory(Path.GetDirectoryName(path)); @@ -377,7 +377,7 @@ private static string GetCredentialsFilePath() return Path.Combine(Path.GetTempPath(), "ngitlab", "credentials.json"); } - private async Task WaitForCiGitLabInstance() + private async Task WaitForCiGitLabInstanceAsync() { Console.WriteLine($"Executing tests on CI. Checking GitLab instance..."); diff --git a/NGitLab.Tests/Docker/GitLabTestContext.cs b/NGitLab.Tests/Docker/GitLabTestContext.cs index 7472a12a..f5209495 100644 --- a/NGitLab.Tests/Docker/GitLabTestContext.cs +++ b/NGitLab.Tests/Docker/GitLabTestContext.cs @@ -62,7 +62,7 @@ public static async Task CreateAsync() // Disable proxy Environment.SetEnvironmentVariable("http_proxy", "", EnvironmentVariableTarget.Process); Environment.SetEnvironmentVariable("https_proxy", "", EnvironmentVariableTarget.Process); - var container = await GitLabDockerContainer.GetOrCreateInstance().ConfigureAwait(false); + var container = await GitLabDockerContainer.GetOrCreateInstanceAsync().ConfigureAwait(false); return new GitLabTestContext(container); } From e7b7a0e16be1f19c3699a52f9800e1a359ff2be5 Mon Sep 17 00:00:00 2001 From: Louis Zanella Date: Thu, 17 Sep 2026 13:07:30 -0400 Subject: [PATCH 4/7] try and make tests more robust --- NGitLab.Tests/Docker/NGitLabRetryAttribute.cs | 7 +++++++ NGitLab.Tests/ProjectsTests.cs | 8 ++++---- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/NGitLab.Tests/Docker/NGitLabRetryAttribute.cs b/NGitLab.Tests/Docker/NGitLabRetryAttribute.cs index 34921c8a..31daeed3 100644 --- a/NGitLab.Tests/Docker/NGitLabRetryAttribute.cs +++ b/NGitLab.Tests/Docker/NGitLabRetryAttribute.cs @@ -1,4 +1,5 @@ using System; +using System.Threading; using NUnit.Framework; using NUnit.Framework.Interfaces; using NUnit.Framework.Internal; @@ -18,6 +19,11 @@ public TestCommand Wrap(TestCommand command) public class RetryCommand : DelegatingTestCommand { + // Some failures are caused by GitLab-side operations still settling asynchronously + // (e.g. a Sidekiq deletion job). Retrying instantly gives the server no time to catch up, + // so back off between attempts instead of hammering the same not-yet-resolved state. + private static readonly TimeSpan RetryDelay = TimeSpan.FromSeconds(3); + private readonly int _tryCount; public RetryCommand(TestCommand innerCommand, int tryCount) @@ -46,6 +52,7 @@ public override TestResult Execute(TestExecutionContext context) if (count > 0) { + Thread.Sleep(RetryDelay); context.CurrentResult = context.CurrentTest.MakeTestResult(); context.CurrentRepeatCount++; // increment Retry count for next iteration. will only happen if we are guaranteed another iteration } diff --git a/NGitLab.Tests/ProjectsTests.cs b/NGitLab.Tests/ProjectsTests.cs index 70256e44..454858cc 100644 --- a/NGitLab.Tests/ProjectsTests.cs +++ b/NGitLab.Tests/ProjectsTests.cs @@ -355,7 +355,7 @@ public async Task CreateUpdateDelete(bool initiallySetTagsInsteadOfTopics) var updatedProject2 = projectClient.Update(createdProject.PathWithNamespace, new ProjectUpdate { Visibility = VisibilityLevel.Internal }); Assert.That(updatedProject2.VisibilityLevel, Is.EqualTo(VisibilityLevel.Internal)); - projectClient.Delete(createdProject.Id); + await projectClient.PermanentlyDeleteAsync(createdProject.Id); } [Test] @@ -701,8 +701,8 @@ public async Task ForkProject() Assert.That(mr.AllowCollaboration, Is.True); - projectClient.Delete(forkedProject.Id); - projectClient.Delete(createdProject.Id); + await projectClient.PermanentlyDeleteAsync(forkedProject.Id); + await projectClient.PermanentlyDeleteAsync(createdProject.Id); } [Test] @@ -810,7 +810,7 @@ public async Task CreateProjectWithSquashOption(SquashOption? inputSquashOption) var expectedSquashOption = inputSquashOption ?? SquashOption.DefaultOff; Assert.That(createdProject.SquashOption, Is.EqualTo(expectedSquashOption)); - projectClient.Delete(createdProject.Id); + await projectClient.PermanentlyDeleteAsync(createdProject.Id); } [Test] From 9e058119c2f53d49f7ff75d7024410b1e8b70577 Mon Sep 17 00:00:00 2001 From: Louis Zanella Date: Fri, 18 Sep 2026 09:21:46 -0400 Subject: [PATCH 5/7] Don't rebuild a DockerClient upon each retry --- NGitLab.Tests/Docker/GitLabDockerContainer.cs | 22 +++++++++++++------ 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/NGitLab.Tests/Docker/GitLabDockerContainer.cs b/NGitLab.Tests/Docker/GitLabDockerContainer.cs index 1841d006..775119c2 100644 --- a/NGitLab.Tests/Docker/GitLabDockerContainer.cs +++ b/NGitLab.Tests/Docker/GitLabDockerContainer.cs @@ -230,7 +230,20 @@ puts token.token """; var retryPolicy = Policy.Handle().WaitAndRetryAsync(20, _ => TimeSpan.FromSeconds(3)); - credentials.AdminUserToken = await retryPolicy.ExecuteAsync(() => RunGitLabRailsRunnerAsync(script)).ConfigureAwait(false); + + if (_localContainer is not null) + { + credentials.AdminUserToken = await retryPolicy.ExecuteAsync(() => RunGitLabRailsRunnerAsync(client: null, script)).ConfigureAwait(false); + } + else + { + using var client = new DockerClientBuilder() + .WithEndpoint(new Uri(OperatingSystem.IsWindows() ? "npipe://./pipe/docker_engine" : "unix:///var/run/docker.sock")) + .Build(); + await ValidateCiDockerIsEnabled(client).ConfigureAwait(false); + + credentials.AdminUserToken = await retryPolicy.ExecuteAsync(() => RunGitLabRailsRunnerAsync(client, script)).ConfigureAwait(false); + } } void GenerateUserToken() @@ -292,7 +305,7 @@ private static async Task ResolveCiGitLabContainerIdAsync(DockerClient c // When we spawned the container ourselves (local dev), Testcontainers already holds a reference to it // and can exec into it directly. On CI, GitLab runs as a pre-existing service container we didn't create, // so we fall back to the raw Docker Engine API to find it and exec into it. - private async Task RunGitLabRailsRunnerAsync(string script) + private async Task RunGitLabRailsRunnerAsync(DockerClient client, string script) { string stdout; string stderr; @@ -305,11 +318,6 @@ private async Task RunGitLabRailsRunnerAsync(string script) } else { - using var client = new DockerClientBuilder() - .WithEndpoint(new Uri(OperatingSystem.IsWindows() ? "npipe://./pipe/docker_engine" : "unix:///var/run/docker.sock")) - .Build(); - await ValidateCiDockerIsEnabled(client).ConfigureAwait(false); - var containerId = await ResolveCiGitLabContainerIdAsync(client).ConfigureAwait(false); var execCreateResponse = await client.Exec.CreateContainerExecAsync(containerId, new ContainerExecCreateParameters { From b20b7b514febd7f7c9f3da912c9b854111e32576 Mon Sep 17 00:00:00 2001 From: Louis Zanella Date: Mon, 21 Sep 2026 07:55:14 -0400 Subject: [PATCH 6/7] Address transient 500s returned by GitLab during tests --- NGitLab.Tests/Docker/GitLabTestContextRequestOptions.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/NGitLab.Tests/Docker/GitLabTestContextRequestOptions.cs b/NGitLab.Tests/Docker/GitLabTestContextRequestOptions.cs index 8d385520..5db90720 100644 --- a/NGitLab.Tests/Docker/GitLabTestContextRequestOptions.cs +++ b/NGitLab.Tests/Docker/GitLabTestContextRequestOptions.cs @@ -25,7 +25,10 @@ internal sealed class GitLabTestContextRequestOptions : RequestOptions public IReadOnlyList AllRequests => _allRequests; public GitLabTestContextRequestOptions() - : base(retryCount: 0, retryInterval: TimeSpan.FromSeconds(1), isIncremental: true) + // GitLab occasionally returns a transient 500 right after an async operation (e.g. a project + // delete) is accepted, before its Sidekiq job has actually started. The base ShouldRetry already + // treats 5xx/408 on idempotent (GET/HEAD/OPTIONS) requests as retryable, so give it retries to work with. + : base(retryCount: 5, retryInterval: TimeSpan.FromSeconds(1), isIncremental: true) { UserAgent = "NGitLab.Tests/1.0.0"; } From e77b49536d80f0f1ff863a4e0889e2ff64058d02 Mon Sep 17 00:00:00 2001 From: Louis Zanella Date: Mon, 21 Sep 2026 08:30:24 -0400 Subject: [PATCH 7/7] Address review comments --- NGitLab.Tests/Docker/GitLabDockerContainer.cs | 97 +++++++++++++------ NGitLab.Tests/SetUpFixture.cs | 10 +- 2 files changed, 74 insertions(+), 33 deletions(-) diff --git a/NGitLab.Tests/Docker/GitLabDockerContainer.cs b/NGitLab.Tests/Docker/GitLabDockerContainer.cs index 775119c2..0c53ed37 100644 --- a/NGitLab.Tests/Docker/GitLabDockerContainer.cs +++ b/NGitLab.Tests/Docker/GitLabDockerContainer.cs @@ -19,7 +19,7 @@ namespace NGitLab.Tests.Docker; -public class GitLabDockerContainer +public sealed class GitLabDockerContainer : IAsyncDisposable { public const string LocalContainerName = "NGitLabClientTests"; public const string ImageName = "gitlab/gitlab-ee"; @@ -100,6 +100,31 @@ public static async Task GetOrCreateInstanceAsync() } } + public static async ValueTask DisposeInstanceAsync() + { + await s_setupLock.WaitAsync().ConfigureAwait(false); + try + { + if (s_instance != null) + { + await s_instance.DisposeAsync().ConfigureAwait(false); + s_instance = null; + } + } + finally + { + s_setupLock.Release(); + } + } + + public async ValueTask DisposeAsync() + { + if (_localContainer is not null) + { + await _localContainer.DisposeAsync().ConfigureAwait(false); + } + } + private async Task SetupAsync() { if (GitLabTestContext.IsContinuousIntegration()) @@ -123,7 +148,7 @@ private async Task SetupAsync() PersistCredentials(); } - private static async Task ValidateCiDockerIsEnabled(DockerClient client) + private static async Task ValidateDockerIsEnabled(DockerClient client) { try { @@ -144,6 +169,13 @@ private async Task SpawnLocalDockerContainerAsync() { Console.WriteLine($"Executing tests locally. Spawning GitLab docker image version '{LocalGitLabDockerVersion}'"); + using (var client = new DockerClientBuilder() + .WithEndpoint(new Uri(OperatingSystem.IsWindows() ? "npipe://./pipe/docker_engine" : "unix:///var/run/docker.sock")) + .Build()) + { + await ValidateDockerIsEnabled(client).ConfigureAwait(false); + } + // Disables non-useful features // See https://gitlab.com/gitlab-org/omnibus-gitlab/blob/master/files/gitlab-config-template/gitlab.rb.template string[] omnibusConfig = @@ -233,16 +265,16 @@ puts token.token if (_localContainer is not null) { - credentials.AdminUserToken = await retryPolicy.ExecuteAsync(() => RunGitLabRailsRunnerAsync(client: null, script)).ConfigureAwait(false); + credentials.AdminUserToken = await retryPolicy.ExecuteAsync(() => RunGitLabRailsRunnerLocallyAsync(script)).ConfigureAwait(false); } else { using var client = new DockerClientBuilder() .WithEndpoint(new Uri(OperatingSystem.IsWindows() ? "npipe://./pipe/docker_engine" : "unix:///var/run/docker.sock")) .Build(); - await ValidateCiDockerIsEnabled(client).ConfigureAwait(false); + await ValidateDockerIsEnabled(client).ConfigureAwait(false); - credentials.AdminUserToken = await retryPolicy.ExecuteAsync(() => RunGitLabRailsRunnerAsync(client, script)).ConfigureAwait(false); + credentials.AdminUserToken = await retryPolicy.ExecuteAsync(() => RunGitLabRailsRunnerOnCiAsync(client, script)).ConfigureAwait(false); } } @@ -289,7 +321,9 @@ void GenerateUserToken() private static async Task ResolveCiGitLabContainerIdAsync(DockerClient client) { - var containers = await client.Containers.ListContainersAsync(new ContainersListParameters { All = true }).ConfigureAwait(false); + // Deliberately omit All = true: it would also match a stopped/stale container left over + // from a previous run, and we only ever want the one GitLab is currently running as. + var containers = await client.Containers.ListContainersAsync(new ContainersListParameters()).ConfigureAwait(false); // On CI, GitLab runs as a pre-existing service container, which isn't named LocalContainerName // (that name is only ever assigned by our own Testcontainers-managed local container), so @@ -302,39 +336,38 @@ private static async Task ResolveCiGitLabContainerIdAsync(DockerClient c return container.ID; } - // When we spawned the container ourselves (local dev), Testcontainers already holds a reference to it - // and can exec into it directly. On CI, GitLab runs as a pre-existing service container we didn't create, - // so we fall back to the raw Docker Engine API to find it and exec into it. - private async Task RunGitLabRailsRunnerAsync(DockerClient client, string script) + // Testcontainers already holds a reference to the container it spawned, so it can exec into it directly. + private async Task RunGitLabRailsRunnerLocallyAsync(string script) + { + var result = await _localContainer.ExecAsync(["gitlab-rails", "runner", script]).ConfigureAwait(false); + return ParseRailsRunnerOutput(result.Stdout, result.Stderr, result.ExitCode); + } + + // On CI, GitLab runs as a pre-existing service container we didn't create, so we fall back + // to the raw Docker Engine API to find it and exec into it. + private static async Task RunGitLabRailsRunnerOnCiAsync(DockerClient client, string script) { + var containerId = await ResolveCiGitLabContainerIdAsync(client).ConfigureAwait(false); + var execCreateResponse = await client.Exec.CreateContainerExecAsync(containerId, new ContainerExecCreateParameters + { + AttachStdout = true, + AttachStderr = true, + Cmd = ["gitlab-rails", "runner", script], + }).ConfigureAwait(false); + string stdout; string stderr; - long? exitCode; - - if (_localContainer is not null) + using (var stream = await client.Exec.StartContainerExecAsync(execCreateResponse.ID, new ContainerExecStartParameters { TTY = false }).ConfigureAwait(false)) { - var result = await _localContainer.ExecAsync(["gitlab-rails", "runner", script]).ConfigureAwait(false); - (stdout, stderr, exitCode) = (result.Stdout, result.Stderr, result.ExitCode); + (stdout, stderr) = await stream.ReadOutputToEndAsync(CancellationToken.None).ConfigureAwait(false); } - else - { - var containerId = await ResolveCiGitLabContainerIdAsync(client).ConfigureAwait(false); - var execCreateResponse = await client.Exec.CreateContainerExecAsync(containerId, new ContainerExecCreateParameters - { - AttachStdout = true, - AttachStderr = true, - Cmd = ["gitlab-rails", "runner", script], - }).ConfigureAwait(false); - using (var stream = await client.Exec.StartContainerExecAsync(execCreateResponse.ID, new ContainerExecStartParameters { TTY = false }).ConfigureAwait(false)) - { - (stdout, stderr) = await stream.ReadOutputToEndAsync(CancellationToken.None).ConfigureAwait(false); - } - - var inspectResponse = await client.Exec.InspectContainerExecAsync(execCreateResponse.ID).ConfigureAwait(false); - exitCode = inspectResponse.ExitCode; - } + var inspectResponse = await client.Exec.InspectContainerExecAsync(execCreateResponse.ID).ConfigureAwait(false); + return ParseRailsRunnerOutput(stdout, stderr, inspectResponse.ExitCode); + } + private static string ParseRailsRunnerOutput(string stdout, string stderr, long? exitCode) + { if (exitCode != 0) throw new InvalidOperationException($"'gitlab-rails runner' failed with exit code {exitCode}.\nStdout: {stdout}\nStderr: {stderr}"); diff --git a/NGitLab.Tests/SetUpFixture.cs b/NGitLab.Tests/SetUpFixture.cs index bf489cff..440c3cd5 100644 --- a/NGitLab.Tests/SetUpFixture.cs +++ b/NGitLab.Tests/SetUpFixture.cs @@ -1,4 +1,6 @@ -using NGitLab.Extensions; +using System.Threading.Tasks; +using NGitLab.Extensions; +using NGitLab.Tests.Docker; using NUnit.Framework; namespace NGitLab.Tests; @@ -11,4 +13,10 @@ public void RunBeforeAnyTests() { FunctionRetryExtensions.Logger = msg => TestContext.Out.WriteLine($"[{TestContext.CurrentContext.Test.FullName}] {msg}"); } + + [OneTimeTearDown] + public async Task RunAfterAllTestsAsync() + { + await GitLabDockerContainer.DisposeInstanceAsync().ConfigureAwait(false); + } }