From 14be76bc96bc857826c7b5129192f01fe4f33482 Mon Sep 17 00:00:00 2001 From: Louis Zanella Date: Wed, 16 Sep 2026 15:21:55 -0400 Subject: [PATCH 1/5] 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 ef381efb..a4caa535 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 94c0f8340ed4590a3f0de4f46bc1c6324fcf8590 Mon Sep 17 00:00:00 2001 From: Louis Zanella Date: Thu, 17 Sep 2026 08:36:50 -0400 Subject: [PATCH 2/5] 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 2bd61dcf37544d71805152e5007c8bf912c13c54 Mon Sep 17 00:00:00 2001 From: Louis Zanella Date: Thu, 17 Sep 2026 10:49:21 -0400 Subject: [PATCH 3/5] 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 c6f9cd962e4e5fdadfc966def2fdb4815b731304 Mon Sep 17 00:00:00 2001 From: Louis Zanella Date: Thu, 17 Sep 2026 13:07:30 -0400 Subject: [PATCH 4/5] 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 81809e49c2f8669d02ab3ecc582b89e197757fd0 Mon Sep 17 00:00:00 2001 From: Louis Zanella Date: Fri, 18 Sep 2026 09:21:46 -0400 Subject: [PATCH 5/5] 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 {