Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add Gitlab CE module #1135

Open
wants to merge 21 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 14 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion Testcontainers.sln
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
Microsoft Visual Studio Solution File, Format Version 12.00
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.0.31903.59
MinimumVisualStudioVersion = 10.0.40219.1
Expand Down Expand Up @@ -195,6 +195,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Testcontainers.Tests", "tes
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Testcontainers.WebDriver.Tests", "tests\Testcontainers.WebDriver.Tests\Testcontainers.WebDriver.Tests.csproj", "{EBA72C3B-57D5-43FF-A5B4-3D55B3B6D4C2}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Testcontainers.Gitlab", "src\Testcontainers.Gitlab\Testcontainers.Gitlab.csproj", "{B3857615-7DD1-41D2-BA74-938DA4469E5E}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Testcontainers.Gitlab.Tests", "tests\Testcontainers.Gitlab.Tests\Testcontainers.Gitlab.Tests.csproj", "{185C65BB-0F79-40D0-A940-7CBE7C8E6A30}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Expand Down Expand Up @@ -568,6 +572,14 @@ Global
{EBA72C3B-57D5-43FF-A5B4-3D55B3B6D4C2}.Debug|Any CPU.Build.0 = Debug|Any CPU
{EBA72C3B-57D5-43FF-A5B4-3D55B3B6D4C2}.Release|Any CPU.ActiveCfg = Release|Any CPU
{EBA72C3B-57D5-43FF-A5B4-3D55B3B6D4C2}.Release|Any CPU.Build.0 = Release|Any CPU
{B3857615-7DD1-41D2-BA74-938DA4469E5E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{B3857615-7DD1-41D2-BA74-938DA4469E5E}.Debug|Any CPU.Build.0 = Debug|Any CPU
{B3857615-7DD1-41D2-BA74-938DA4469E5E}.Release|Any CPU.ActiveCfg = Release|Any CPU
{B3857615-7DD1-41D2-BA74-938DA4469E5E}.Release|Any CPU.Build.0 = Release|Any CPU
{185C65BB-0F79-40D0-A940-7CBE7C8E6A30}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{185C65BB-0F79-40D0-A940-7CBE7C8E6A30}.Debug|Any CPU.Build.0 = Debug|Any CPU
{185C65BB-0F79-40D0-A940-7CBE7C8E6A30}.Release|Any CPU.ActiveCfg = Release|Any CPU
{185C65BB-0F79-40D0-A940-7CBE7C8E6A30}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{5365F780-0E6C-41F0-B1B9-7DC34368F80C} = {673F23AE-7694-4BB9-ABD4-136D6C13634E}
Expand Down Expand Up @@ -661,5 +673,7 @@ Global
{1A1983E6-5297-435F-B467-E8E1F11277D6} = {7164F1FB-7F24-444A-ACD2-2C329C2B3CCF}
{27CDB869-A150-4593-958F-6F26E5391E7C} = {7164F1FB-7F24-444A-ACD2-2C329C2B3CCF}
{EBA72C3B-57D5-43FF-A5B4-3D55B3B6D4C2} = {7164F1FB-7F24-444A-ACD2-2C329C2B3CCF}
{B3857615-7DD1-41D2-BA74-938DA4469E5E} = {673F23AE-7694-4BB9-ABD4-136D6C13634E}
{185C65BB-0F79-40D0-A940-7CBE7C8E6A30} = {7164F1FB-7F24-444A-ACD2-2C329C2B3CCF}
EndGlobalSection
EndGlobal
1 change: 1 addition & 0 deletions src/Testcontainers.Gitlab/.editorconfig
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
root = true
78 changes: 78 additions & 0 deletions src/Testcontainers.Gitlab/GitlabBuilder.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
namespace Testcontainers.Gitlab;

/// <inheritdoc cref="ContainerBuilder{TBuilderEntity, TContainerEntity, TConfigurationEntity}" />
[PublicAPI]
public sealed class GitlabBuilder : ContainerBuilder<GitlabBuilder, GitlabContainer, GitlabConfiguration>
{
/// <summary>
/// This is the default image for gitlab community edition.
/// </summary>
public const string GitlabImage = "gitlab/gitlab-ce";
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we please pin the version?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure thing. I tested with the latest image, with is version 16.11.1 .

/// <summary>
/// This port is used for http communication to gitlab instance.
/// </summary>

public const ushort GitlabHttpPort = 80;
/// <summary>
/// This port is used for ssh communication to gitlab instance.
/// </summary>
public const ushort GitlabSshPort = 22;

/// <summary>
/// Initializes a new instance of the <see cref="GitlabBuilder" /> class.
/// </summary>
public GitlabBuilder()
: this(new GitlabConfiguration())
{
DockerResourceConfiguration = Init().DockerResourceConfiguration;
}

/// <summary>
/// Initializes a new instance of the <see cref="GitlabBuilder" /> class.
/// </summary>
/// <param name="resourceConfiguration">The Docker resource configuration.</param>
private GitlabBuilder(GitlabConfiguration resourceConfiguration)
: base(resourceConfiguration)
{
DockerResourceConfiguration = resourceConfiguration;
}

/// <inheritdoc />
protected override GitlabConfiguration DockerResourceConfiguration { get; }

/// <inheritdoc />
public override GitlabContainer Build()
{
Validate();
return new GitlabContainer(DockerResourceConfiguration);
}

/// <inheritdoc />
protected override GitlabBuilder Init()
{
return base.Init()
.WithImage(GitlabImage)
.WithPortBinding(GitlabHttpPort, true)
.WithPortBinding(GitlabSshPort, true)
.WithWaitStrategy(Wait.ForUnixContainer().UntilFileExists("/etc/gitlab/initial_root_password"))
.WithWaitStrategy(Wait.ForUnixContainer().UntilPortIsAvailable(80))
.WithWaitStrategy(Wait.ForUnixContainer().UntilPortIsAvailable(22))
.WithWaitStrategy(Wait.ForUnixContainer().UntilContainerIsHealthy())
.WithWaitStrategy(Wait.ForUnixContainer().UntilHttpRequestIsSucceeded(request => request.ForPath("/users/sign_in").ForStatusCode(HttpStatusCode.OK)));
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This overrides the previous wait strategies. You need to add the wait strategy once (you can chain the until members though).

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've fixed the WaitStrategy so that the previous strategy will not be overwritten.

}

/// <inheritdoc />
protected override void Validate() => base.Validate();

/// <inheritdoc />
protected override GitlabBuilder Clone(IResourceConfiguration<CreateContainerParameters> resourceConfiguration)
=> Merge(DockerResourceConfiguration, new GitlabConfiguration(resourceConfiguration));

/// <inheritdoc />
protected override GitlabBuilder Clone(IContainerConfiguration resourceConfiguration)
=> Merge(DockerResourceConfiguration, new GitlabConfiguration(resourceConfiguration));

/// <inheritdoc />
protected override GitlabBuilder Merge(GitlabConfiguration oldValue, GitlabConfiguration newValue)
=> new(new GitlabConfiguration(oldValue, newValue));
}
53 changes: 53 additions & 0 deletions src/Testcontainers.Gitlab/GitlabConfiguration.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
namespace Testcontainers.Gitlab;

/// <inheritdoc cref="ContainerConfiguration" />
[PublicAPI]
public sealed class GitlabConfiguration : ContainerConfiguration
{
/// <summary>
/// Initializes a new instance of the <see cref="GitlabConfiguration" /> class.
/// </summary>
public GitlabConfiguration()
{
}

/// <summary>
/// Initializes a new instance of the <see cref="GitlabConfiguration" /> class.
/// </summary>
/// <param name="resourceConfiguration">The Docker resource configuration.</param>
public GitlabConfiguration(IResourceConfiguration<CreateContainerParameters> resourceConfiguration)
: base(resourceConfiguration)
{
// Passes the configuration upwards to the base implementations to create an updated immutable copy.
}

/// <summary>
/// Initializes a new instance of the <see cref="GitlabConfiguration" /> class.
/// </summary>
/// <param name="resourceConfiguration">The Docker resource configuration.</param>
public GitlabConfiguration(IContainerConfiguration resourceConfiguration)
: base(resourceConfiguration)
{
// Passes the configuration upwards to the base implementations to create an updated immutable copy.
}

/// <summary>
/// Initializes a new instance of the <see cref="GitlabConfiguration" /> class.
/// </summary>
/// <param name="resourceConfiguration">The Docker resource configuration.</param>
public GitlabConfiguration(GitlabConfiguration resourceConfiguration)
: this(new GitlabConfiguration(), resourceConfiguration)
{
// Passes the configuration upwards to the base implementations to create an updated immutable copy.
}

/// <summary>
/// Initializes a new instance of the <see cref="GitlabConfiguration" /> class.
/// </summary>
/// <param name="oldValue">The old Docker resource configuration.</param>
/// <param name="newValue">The new Docker resource configuration.</param>
public GitlabConfiguration(GitlabConfiguration oldValue, GitlabConfiguration newValue)
: base(oldValue, newValue)
{
}
}
93 changes: 93 additions & 0 deletions src/Testcontainers.Gitlab/GitlabContainer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
using Testcontainers.Gitlab.Models;
using Testcontainers.Gitlab.RegexPatterns;

namespace Testcontainers.Gitlab;

/// <inheritdoc cref="DockerContainer" />
/// <summary>
/// Initializes a new instance of the <see cref="GitlabContainer" /> class.
/// </summary>
/// <param name="configuration">The container configuration.</param>
[PublicAPI]
public sealed class GitlabContainer(GitlabConfiguration configuration) : DockerContainer(configuration)
{
/// <summary>
/// The password for the root user
/// </summary>
public string RootPassword { get; private set; }

public override async Task StartAsync(CancellationToken ct = default)
{
await base.StartAsync(ct);
RootPassword = await GetInitialRootPassword();
}

/// <summary>
/// Generate a personal access token.
/// </summary>
/// <param name="pat">The personal access token to create.</param>
/// <returns></returns>
/// <exception cref="DataMisalignedException"></exception>
public async Task<PersonalAccessToken> GenerateAccessToken(PersonalAccessToken pat)
{
var scope = "[" + '\'' + pat.Scope.ToString().Replace(", ", "\', \'") + '\'' + "]";

var command = $"token = User.find_by_username('{pat.User}')" +
$".personal_access_tokens" +
$".create(name: '{pat.Name}', scopes: {scope}, expires_at: {pat.ExpirationInDays}.days.from_now); " +
$"puts token.cleartext_tokens";

var tokenCommand = new List<string>{
{ "gitlab-rails" },
{ "runner" },
{ command }
};

ExecResult tokenResult = await ExecAsync(tokenCommand);

string token = "";
if (tokenResult.ExitCode == 0)
{
var match = GitlabRegex.GitlabPersonalAccessToken.Match(tokenResult.Stdout);
token = match.Value;
}
else
{
throw new DataMisalignedException("Stderr: " + tokenResult.Stderr + "|" + "Stdout: " + tokenResult.Stdout);
}
pat.TokenInternal = token;
return pat;
}

/// <summary>
/// Generate a personal access token.
/// </summary>
/// <param name="name">Name of the personal access token. If left empty a GUID will be used.</param>
/// <param name="user">The name of the user that owns this personal access token.</param>
/// <param name="scope">The scope that will be given to the token.</param>
/// <param name="expirationInDays">Days until the tokens expires.</param>
/// <returns></returns>
public async Task<PersonalAccessToken> GenerateAccessToken(string user, PersonalAccessTokenScopes scope, string name = "", int expirationInDays = 365)
=> await GenerateAccessToken(new PersonalAccessToken
{
Name = string.IsNullOrWhiteSpace(name) ? Guid.NewGuid().ToString() : name,
User = user,
Scope = scope,
ExpirationInDays = expirationInDays
});


/// <summary>
/// This method returns the initial root password for the gitlab root user.
/// </summary>
/// <returns>Returns the initial root password generated by gitlab.</returns>
private async Task<string> GetInitialRootPassword()
{
var byteArray = await ReadFileAsync("/etc/gitlab/initial_root_password");
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Testcontainers allows uploading files before the container starts. Can we upload the initial_root_password file to specify the password? Can we simply use basic authentication? This would make the module simpler and should be enough for testing purposes (I guess).

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah I checked last night if there is some easier method to provide a root password and there a sereval ways. You can set the root password with an environment variable named GITLAB_ROOT_PASSWORD (see here for dokumentation). Another way would be to provide a file named root_password.txt in the root directory. This is documented here.

I would strongly prefer to set the root password via an environment variable, would either way would be valid. I try to find some time this week to resolve this.

If you have an preferred implementation than just let me know.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should be fixed now. I've selected the approach via an environment variable just to keep it simple.


string fileContent = Encoding.UTF8.GetString(byteArray);
var match = GitlabRegex.GitlabRootPassword.Match(fileContent);
string[] splits = match.Value.Split(' ');
return splits[1];
}
}
36 changes: 36 additions & 0 deletions src/Testcontainers.Gitlab/Models/PersonalAccessToken.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
namespace Testcontainers.Gitlab.Models;

/// <summary>
/// The personal access token that is used to authenticate against the API from gitlab.
/// </summary>
public record PersonalAccessToken
{
/// <param name="name">Name of the personal access token. If left empty a GUID will be used.</param>
/// <param name="user">The name of the user that owns this personal access token.</param>
/// <param name="scope">The scope that will be given to the token.</param>
/// <param name="expirationInDays">Days until the tokens expires.</param>
/// <summary>
/// Name of the personal access token. If left empty a GUID will be used.
/// </summary>
public string Name { get; set; } = string.Empty;
/// <summary>
/// The name of the user that owns this personal access token.
/// </summary>
public string User { get; set; } = string.Empty;
/// <summary>
/// The scope that will be given to the token.
/// </summary>
public PersonalAccessTokenScopes Scope { get; set; } = PersonalAccessTokenScopes.None;
/// <summary>
/// Days until the tokens expires.
/// </summary>
public int ExpirationInDays { get; set; } = 365;
/// <summary>
/// Internal token that is used to set the token publically.
/// </summary>
internal string TokenInternal { get; set; } = string.Empty;
/// <summary>
/// The token that will be generated.
/// </summary>
public string Token => TokenInternal;
}
18 changes: 18 additions & 0 deletions src/Testcontainers.Gitlab/Models/PersonalAccessTokenScopes.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
namespace Testcontainers.Gitlab.Models
{
[Flags]
public enum PersonalAccessTokenScopes
{
None = 0,
api = 2,
read_api = 4,
read_user = 8,
read_repository = 16,
write_repository = 32,
read_registry = 64,
write_registry = 128,
create_runner = 256,
ai_features = 512,
k8s_proxy = 1024,
}
}
18 changes: 18 additions & 0 deletions src/Testcontainers.Gitlab/RegexPatterns/GitlabRegex.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
using System.Text.RegularExpressions;

namespace Testcontainers.Gitlab.RegexPatterns;

/// <summary>
/// This class contains regex patterns that are used in gitlab.
/// </summary>
public static partial class GitlabRegex
{
/// <summary>
/// GitLab Personal Access Token
/// </summary>
public static Regex GitlabPersonalAccessToken => new(@"glpat-[0-9a-zA-Z_\-]{20}", RegexOptions.Compiled | RegexOptions.IgnoreCase);
/// <summary>
/// Regex Pattern to find the gitlab root password
/// </summary>
public static Regex GitlabRootPassword => new(@"Password: .*", RegexOptions.Compiled | RegexOptions.IgnoreCase);
}
12 changes: 12 additions & 0 deletions src/Testcontainers.Gitlab/Testcontainers.Gitlab.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>net6.0;net8.0;netstandard2.0;netstandard2.1</TargetFrameworks>
<LangVersion>latest</LangVersion>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="JetBrains.Annotations" VersionOverride="2023.3.0" PrivateAssets="All"/>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../Testcontainers/Testcontainers.csproj"/>
</ItemGroup>
</Project>
15 changes: 15 additions & 0 deletions src/Testcontainers.Gitlab/Usings.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
global using System;
global using System.Collections.Generic;
global using System.IO;
global using System.Net;
global using System.Linq;
global using System.Text;
global using System.Threading;
global using System.Threading.Tasks;
global using Docker.DotNet.Models;
global using DotNet.Testcontainers;
global using DotNet.Testcontainers.Builders;
global using DotNet.Testcontainers.Configurations;
global using DotNet.Testcontainers.Containers;
global using JetBrains.Annotations;
global using Microsoft.Extensions.Logging;
1 change: 1 addition & 0 deletions tests/Testcontainers.Gitlab.Tests/.editorconfig
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
root = true
Loading
Loading