Skip to content

Commit

Permalink
feat: Get Docker endpoint from Docker context (#1235)
Browse files Browse the repository at this point in the history
Co-authored-by: Andre Hofmeister <[email protected]>
  • Loading branch information
0xced and HofmeisterAn authored Sep 6, 2024
1 parent 4d05084 commit 5a9f8f1
Show file tree
Hide file tree
Showing 15 changed files with 515 additions and 49 deletions.
23 changes: 23 additions & 0 deletions docs/custom_configuration/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ Testcontainers supports various configurations to set up your test environment.
|-----------------------------|--------------------------------------------|---------------------------------------------------------------------------------------------------------------------------|-----------------------------|
| `docker.config` | `DOCKER_CONFIG` | The directory path that contains the Docker configuration (`config.json`) file. | `~/.docker/` |
| `docker.host` | `DOCKER_HOST` | The Docker daemon socket to connect to. | - |
| `docker.context` | `DOCKER_CONTEXT` | The Docker context to connect to. | - |
| `docker.auth.config` | `DOCKER_AUTH_CONFIG` | The Docker configuration file content (GitLab: [Use statically-defined credentials][use-statically-defined-credentials]). | - |
| `docker.cert.path` | `DOCKER_CERT_PATH` | The directory path that contains the client certificate (`{ca,cert,key}.pem`) files. | `~/.docker/` |
| `docker.tls` | `DOCKER_TLS` | Enables TLS. | `false` |
Expand Down Expand Up @@ -36,6 +37,28 @@ To configure a remote container runtime, Testcontainers provides support for Doc
docker.host=tcp://docker:2375
```

## Use a different context

You can switch between contexts using the properties file or an environment variable. Once the context is set, Testcontainers will connect to the specified endpoint based on the given value.

```title="List available contexts"
PS C:\Sources\dotnet\testcontainers-dotnet> docker context ls
NAME DESCRIPTION DOCKER ENDPOINT ERROR
tcc tcp://127.0.0.1:60706/0
```

Setting the context to `tcc` in this example will use the Docker host running at `127.0.0.1:60706` to create and run the test resources.

=== "Environment Variable"
```
DOCKER_CONTEXT=tcc
```

=== "Properties File"
```
docker.context=tcc
```

## Enable logging

In .NET logging usually goes through the test framework. Testcontainers is not aware of the project's test framework and may not forward log messages to the appropriate output stream. The default implementation forwards log messages to the `Console` (respectively `stdout` and `stderr`). The output should at least pop up in the IDE running tests in the `Debug` configuration. To override the default implementation, use the builder's `WithLogger(ILogger)` method and provide an `ILogger` instance to replace the default console logger.
Expand Down
202 changes: 202 additions & 0 deletions src/Testcontainers/Builders/DockerConfig.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
namespace DotNet.Testcontainers.Builders
{
using System;
using System.IO;
using System.Linq;
using System.Runtime.InteropServices;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using DotNet.Testcontainers.Configurations;
using JetBrains.Annotations;

/// <summary>
/// Represents a Docker config.
/// </summary>
internal sealed class DockerConfig
{
private static readonly string UserProfileDockerConfigDirectoryPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".docker");

private readonly ICustomConfiguration[] _customConfigurations;

private readonly string _dockerConfigDirectoryPath;

private readonly string _dockerConfigFilePath;

/// <summary>
/// Initializes a new instance of the <see cref="DockerConfig" /> class.
/// </summary>
[PublicAPI]
public DockerConfig()
: this(EnvironmentConfiguration.Instance, PropertiesFileConfiguration.Instance)
{
}

/// <summary>
/// Initializes a new instance of the <see cref="DockerConfig" /> class.
/// </summary>
/// <param name="customConfigurations">A list of custom configurations.</param>
[PublicAPI]
public DockerConfig(params ICustomConfiguration[] customConfigurations)
{
_customConfigurations = customConfigurations;
_dockerConfigDirectoryPath = GetDockerConfig();
_dockerConfigFilePath = Path.Combine(_dockerConfigDirectoryPath, "config.json");
}

/// <summary>
/// Gets the <see cref="DockerConfig" /> instance.
/// </summary>
public static DockerConfig Instance { get; }
= new DockerConfig();

/// <inheritdoc cref="FileInfo.Exists" />
public bool Exists => File.Exists(_dockerConfigFilePath);

/// <inheritdoc cref="FileInfo.FullName" />
public string FullName => _dockerConfigFilePath;

/// <summary>
/// Parses the Docker config file.
/// </summary>
/// <returns>A <see cref="JsonDocument" /> representing the Docker config.</returns>
public JsonDocument Parse()
{
using (var dockerConfigFileStream = File.OpenRead(_dockerConfigFilePath))
{
return JsonDocument.Parse(dockerConfigFileStream);
}
}

/// <summary>
/// Gets the current Docker endpoint.
/// </summary>
/// <remarks>
/// See the Docker CLI implementation <a href="https://github.com/docker/cli/blob/v25.0.0/cli/command/cli.go#L364-L390">comments</a>.
/// Executes a command equivalent to <c>docker context inspect --format {{.Endpoints.docker.Host}}</c>.
/// </remarks>
/// A <see cref="Uri" /> representing the current Docker endpoint if available; otherwise, <c>null</c>.
[CanBeNull]
public Uri GetCurrentEndpoint()
{
const string defaultDockerContext = "default";

var dockerHost = GetDockerHost();
if (dockerHost != null)
{
return dockerHost;
}

var dockerContext = GetCurrentContext();
if (string.IsNullOrEmpty(dockerContext) || defaultDockerContext.Equals(dockerContext))
{
return RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? NpipeEndpointAuthenticationProvider.DockerEngine : UnixEndpointAuthenticationProvider.DockerEngine;
}

using (var sha256 = SHA256.Create())
{
var dockerContextHash = BitConverter.ToString(sha256.ComputeHash(Encoding.Default.GetBytes(dockerContext))).Replace("-", string.Empty).ToLowerInvariant();
var metaFilePath = Path.Combine(_dockerConfigDirectoryPath, "contexts", "meta", dockerContextHash, "meta.json");

if (!File.Exists(metaFilePath))
{
return null;
}

using (var metaFileStream = File.OpenRead(metaFilePath))
{
var meta = JsonSerializer.Deserialize(metaFileStream, SourceGenerationContext.Default.DockerContextMeta);
var host = meta?.Name == dockerContext ? meta.Endpoints?.Docker?.Host : null;
return string.IsNullOrEmpty(host) ? null : new Uri(host.Replace("npipe:////./", "npipe://./"));
}
}
}

[CanBeNull]
private string GetCurrentContext()
{
var dockerContext = GetDockerContext();
if (!string.IsNullOrEmpty(dockerContext))
{
return dockerContext;
}

if (!Exists)
{
return null;
}

using (var dockerConfigJsonDocument = Parse())
{
if (dockerConfigJsonDocument.RootElement.TryGetProperty("currentContext", out var currentContext) && currentContext.ValueKind == JsonValueKind.String)
{
return currentContext.GetString();
}
else
{
return null;
}
}
}

[NotNull]
private string GetDockerConfig()
{
var dockerConfigDirectoryPath = _customConfigurations.Select(customConfiguration => customConfiguration.GetDockerConfig()).FirstOrDefault(dockerConfig => !string.IsNullOrEmpty(dockerConfig));
return dockerConfigDirectoryPath ?? UserProfileDockerConfigDirectoryPath;
}

[CanBeNull]
private Uri GetDockerHost()
{
return _customConfigurations.Select(customConfiguration => customConfiguration.GetDockerHost()).FirstOrDefault(dockerHost => dockerHost != null);
}

[CanBeNull]
private string GetDockerContext()
{
return _customConfigurations.Select(customConfiguration => customConfiguration.GetDockerContext()).FirstOrDefault(dockerContext => !string.IsNullOrEmpty(dockerContext));
}

internal sealed class DockerContextMeta
{
[JsonConstructor]
public DockerContextMeta(string name, DockerContextMetaEndpoints endpoints)
{
Name = name;
Endpoints = endpoints;
}

[JsonPropertyName("Name")]
public string Name { get; }

[JsonPropertyName("Endpoints")]
public DockerContextMetaEndpoints Endpoints { get; }
}

internal sealed class DockerContextMetaEndpoints
{
[JsonConstructor]
public DockerContextMetaEndpoints(DockerContextMetaEndpointsDocker docker)
{
Docker = docker;
}

[JsonPropertyName("docker")]
public DockerContextMetaEndpointsDocker Docker { get; }
}

internal sealed class DockerContextMetaEndpointsDocker
{
[JsonConstructor]
public DockerContextMetaEndpointsDocker(string host)
{
Host = host;
}

[JsonPropertyName("Host")]
public string Host { get; }
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ internal sealed class DockerDesktopEndpointAuthenticationProvider : RootlessUnix
/// Initializes a new instance of the <see cref="DockerDesktopEndpointAuthenticationProvider" /> class.
/// </summary>
public DockerDesktopEndpointAuthenticationProvider()
: base(GetSocketPathFromHomeDesktopDir(), GetSocketPathFromHomeRunDir())
: base(DockerConfig.Instance.GetCurrentEndpoint()?.AbsolutePath, GetSocketPathFromHomeDesktopDir(), GetSocketPathFromHomeRunDir())
{
}

Expand All @@ -37,6 +37,12 @@ public Uri GetDockerHost()
return null;
}

/// <inheritdoc />
public string GetDockerContext()
{
return null;
}

/// <inheritdoc />
public string GetDockerHostOverride()
{
Expand Down
58 changes: 18 additions & 40 deletions src/Testcontainers/Builders/DockerRegistryAuthenticationProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,7 @@ internal sealed class DockerRegistryAuthenticationProvider : IDockerRegistryAuth

private static readonly ConcurrentDictionary<string, Lazy<IDockerRegistryAuthenticationConfiguration>> Credentials = new ConcurrentDictionary<string, Lazy<IDockerRegistryAuthenticationConfiguration>>();

private static readonly string UserProfileDockerConfigDirectoryPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".docker");

private readonly FileInfo _dockerConfigFilePath;
private readonly DockerConfig _dockerConfig;

private readonly ILogger _logger;

Expand All @@ -28,30 +26,19 @@ internal sealed class DockerRegistryAuthenticationProvider : IDockerRegistryAuth
/// <param name="logger">The logger.</param>
[PublicAPI]
public DockerRegistryAuthenticationProvider(ILogger logger)
: this(GetDefaultDockerConfigFilePath(), logger)
{
}

/// <summary>
/// Initializes a new instance of the <see cref="DockerRegistryAuthenticationProvider" /> class.
/// </summary>
/// <param name="dockerConfigFilePath">The Docker config file path.</param>
/// <param name="logger">The logger.</param>
[PublicAPI]
public DockerRegistryAuthenticationProvider(string dockerConfigFilePath, ILogger logger)
: this(new FileInfo(dockerConfigFilePath), logger)
: this(DockerConfig.Instance, logger)
{
}

/// <summary>
/// Initializes a new instance of the <see cref="DockerRegistryAuthenticationProvider" /> class.
/// </summary>
/// <param name="dockerConfigFilePath">The Docker config file path.</param>
/// <param name="dockerConfig">The Docker config.</param>
/// <param name="logger">The logger.</param>
[PublicAPI]
public DockerRegistryAuthenticationProvider(FileInfo dockerConfigFilePath, ILogger logger)
public DockerRegistryAuthenticationProvider(DockerConfig dockerConfig, ILogger logger)
{
_dockerConfigFilePath = dockerConfigFilePath;
_dockerConfig = dockerConfig;
_logger = logger;
}

Expand All @@ -68,12 +55,6 @@ public IDockerRegistryAuthenticationConfiguration GetAuthConfig(string hostname)
return lazyAuthConfig.Value;
}

private static string GetDefaultDockerConfigFilePath()
{
var dockerConfigDirectoryPath = EnvironmentConfiguration.Instance.GetDockerConfig() ?? PropertiesFileConfiguration.Instance.GetDockerConfig() ?? UserProfileDockerConfigDirectoryPath;
return Path.Combine(dockerConfigDirectoryPath, "config.json");
}

private static JsonDocument GetDefaultDockerAuthConfig()
{
return EnvironmentConfiguration.Instance.GetDockerAuthConfig() ?? PropertiesFileConfiguration.Instance.GetDockerAuthConfig() ?? JsonDocument.Parse("{}");
Expand All @@ -85,28 +66,25 @@ private IDockerRegistryAuthenticationConfiguration GetUncachedAuthConfig(string
{
IDockerRegistryAuthenticationConfiguration authConfig;

if (_dockerConfigFilePath.Exists)
if (_dockerConfig.Exists)
{
using (var dockerConfigFileStream = new FileStream(_dockerConfigFilePath.FullName, FileMode.Open, FileAccess.Read))
using (var dockerConfigJsonDocument = _dockerConfig.Parse())
{
using (var dockerConfigJsonDocument = JsonDocument.Parse(dockerConfigFileStream))
{
authConfig = new IDockerRegistryAuthenticationProvider[]
{
new CredsHelperProvider(dockerConfigJsonDocument, _logger),
new CredsStoreProvider(dockerConfigJsonDocument, _logger),
new Base64Provider(dockerConfigJsonDocument, _logger),
new Base64Provider(dockerAuthConfigJsonDocument, _logger),
}
.AsParallel()
.Select(authenticationProvider => authenticationProvider.GetAuthConfig(hostname))
.FirstOrDefault(authenticationProvider => authenticationProvider != null);
}
authConfig = new IDockerRegistryAuthenticationProvider[]
{
new CredsHelperProvider(dockerConfigJsonDocument, _logger),
new CredsStoreProvider(dockerConfigJsonDocument, _logger),
new Base64Provider(dockerConfigJsonDocument, _logger),
new Base64Provider(dockerAuthConfigJsonDocument, _logger),
}
.AsParallel()
.Select(authenticationProvider => authenticationProvider.GetAuthConfig(hostname))
.FirstOrDefault(authenticationProvider => authenticationProvider != null);
}
}
else
{
_logger.DockerConfigFileNotFound(_dockerConfigFilePath.FullName);
_logger.DockerConfigFileNotFound(_dockerConfig.FullName);
IDockerRegistryAuthenticationProvider authConfigProvider = new Base64Provider(dockerAuthConfigJsonDocument, _logger);
authConfig = authConfigProvider.GetAuthConfig(hostname);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,8 @@ public RootlessUnixEndpointAuthenticationProvider()
/// <param name="socketPaths">A list of socket paths.</param>
public RootlessUnixEndpointAuthenticationProvider(params string[] socketPaths)
{
DockerEngine = socketPaths
.Where(File.Exists)
.Select(socketPath => new Uri("unix://" + socketPath))
.FirstOrDefault();
var socketPath = socketPaths.FirstOrDefault(File.Exists);

Check warning on line 30 in src/Testcontainers/Builders/RootlessUnixEndpointAuthenticationProvider.cs

View workflow job for this annotation

GitHub Actions / publish

"Array.Find" static method should be used instead of the "FirstOrDefault" extension method. (https://rules.sonarsource.com/csharp/RSPEC-6602)
DockerEngine = socketPath == null ? null : new Uri("unix://" + socketPath);
}

/// <summary>
Expand Down
7 changes: 7 additions & 0 deletions src/Testcontainers/Builders/SourceGenerationContext.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
namespace DotNet.Testcontainers.Builders
{
using System.Text.Json.Serialization;

[JsonSerializable(typeof(DockerConfig.DockerContextMeta))]
internal partial class SourceGenerationContext : JsonSerializerContext;
}
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,12 @@ public Uri GetDockerHost()
return _customConfiguration.GetDockerHost();
}

/// <inheritdoc />
public string GetDockerContext()
{
return _customConfiguration.GetDockerContext();
}

/// <inheritdoc />
public string GetDockerHostOverride()
{
Expand Down
Loading

0 comments on commit 5a9f8f1

Please sign in to comment.