From 1e95bea7701960d4510fd8ba74c8767d58b600c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Luthi?= Date: Tue, 26 Nov 2024 13:35:40 +0100 Subject: [PATCH] Improve error reporting when loading the Docker configuration file --- src/Testcontainers/Builders/DockerConfig.cs | 33 ++++++---- .../Builders/DockerConfigurationException.cs | 29 ++++++++ ...erDesktopEndpointAuthenticationProvider.cs | 2 +- ...tlessUnixEndpointAuthenticationProvider.cs | 9 +++ .../Unit/Builders/DockerConfigTest.cs | 66 +++++++++++++------ 5 files changed, 105 insertions(+), 34 deletions(-) create mode 100644 src/Testcontainers/Builders/DockerConfigurationException.cs diff --git a/src/Testcontainers/Builders/DockerConfig.cs b/src/Testcontainers/Builders/DockerConfig.cs index 9e48bc29b..38bd4dcb8 100644 --- a/src/Testcontainers/Builders/DockerConfig.cs +++ b/src/Testcontainers/Builders/DockerConfig.cs @@ -77,7 +77,7 @@ public JsonDocument Parse() /// Executes a command equivalent to docker context inspect --format {{.Endpoints.docker.Host}}. /// /// A representing the current Docker endpoint if available; otherwise, null. - [CanBeNull] + [NotNull] public Uri GetCurrentEndpoint() { const string defaultDockerContext = "default"; @@ -99,16 +99,27 @@ public Uri GetCurrentEndpoint() 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)) + try { - return null; + using (var metaFileStream = File.OpenRead(metaFilePath)) + { + var meta = JsonSerializer.Deserialize(metaFileStream, SourceGenerationContext.Default.DockerContextMeta); + var host = meta.Endpoints?.Docker?.Host; + if (host == null) + { + throw new DockerConfigurationException($"The Docker host is null in {metaFilePath} (JSONPath: Endpoints.docker.Host)"); + } + + return new Uri(host.Replace("npipe:////./", "npipe://./")); + } } - - using (var metaFileStream = File.OpenRead(metaFilePath)) + catch (Exception notFoundException) when (notFoundException is DirectoryNotFoundException or FileNotFoundException) { - 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://./")); + throw new DockerConfigurationException($"The Docker context '{dockerContext}' does not exist", notFoundException); + } + catch (Exception exception) when (exception is not DockerConfigurationException) + { + throw new DockerConfigurationException($"The Docker context '{dockerContext}' failed to load from {metaFilePath}", exception); } } } @@ -162,15 +173,11 @@ private string GetDockerContext() internal sealed class DockerContextMeta { [JsonConstructor] - public DockerContextMeta(string name, DockerContextMetaEndpoints endpoints) + public DockerContextMeta(DockerContextMetaEndpoints endpoints) { - Name = name; Endpoints = endpoints; } - [JsonPropertyName("Name")] - public string Name { get; } - [JsonPropertyName("Endpoints")] public DockerContextMetaEndpoints Endpoints { get; } } diff --git a/src/Testcontainers/Builders/DockerConfigurationException.cs b/src/Testcontainers/Builders/DockerConfigurationException.cs new file mode 100644 index 000000000..adc4e772c --- /dev/null +++ b/src/Testcontainers/Builders/DockerConfigurationException.cs @@ -0,0 +1,29 @@ +namespace DotNet.Testcontainers.Builders +{ + using System; + using JetBrains.Annotations; + + /// + /// The exception that is thrown when the Docker configuration file cannot be read successfully. + /// + [PublicAPI] + public sealed class DockerConfigurationException : Exception + { + /// + /// Initializes a new instance of the class, using the provided message. + /// + /// The error message that explains the reason for the exception. + public DockerConfigurationException(string message) : base(message) + { + } + + /// + /// Initializes a new instance of the class, using the provided message and exception that is the cause of this exception. + /// + /// The error message that explains the reason for the exception. + /// The exception that is the cause of the current exception. + public DockerConfigurationException(string message, Exception innerException) : base(message, innerException) + { + } + } +} diff --git a/src/Testcontainers/Builders/DockerDesktopEndpointAuthenticationProvider.cs b/src/Testcontainers/Builders/DockerDesktopEndpointAuthenticationProvider.cs index c7eddad4a..67c9f7b89 100644 --- a/src/Testcontainers/Builders/DockerDesktopEndpointAuthenticationProvider.cs +++ b/src/Testcontainers/Builders/DockerDesktopEndpointAuthenticationProvider.cs @@ -15,7 +15,7 @@ internal sealed class DockerDesktopEndpointAuthenticationProvider : RootlessUnix /// Initializes a new instance of the class. /// public DockerDesktopEndpointAuthenticationProvider() - : base(DockerConfig.Instance.GetCurrentEndpoint()?.AbsolutePath, GetSocketPathFromHomeDesktopDir(), GetSocketPathFromHomeRunDir()) + : base(DockerConfig.Instance.GetCurrentEndpoint()) { } diff --git a/src/Testcontainers/Builders/RootlessUnixEndpointAuthenticationProvider.cs b/src/Testcontainers/Builders/RootlessUnixEndpointAuthenticationProvider.cs index e8e1d6f6d..7fbde86e1 100644 --- a/src/Testcontainers/Builders/RootlessUnixEndpointAuthenticationProvider.cs +++ b/src/Testcontainers/Builders/RootlessUnixEndpointAuthenticationProvider.cs @@ -30,6 +30,15 @@ public RootlessUnixEndpointAuthenticationProvider(params string[] socketPaths) DockerEngine = socketPath == null ? null : new Uri("unix://" + socketPath); } + /// + /// Initializes a new instance of the class. + /// + /// The Unix socket Docker Engine endpoint. + public RootlessUnixEndpointAuthenticationProvider(Uri dockerEngine) + { + DockerEngine = dockerEngine; + } + /// /// Gets the Unix socket Docker Engine endpoint. /// diff --git a/tests/Testcontainers.Tests/Unit/Builders/DockerConfigTest.cs b/tests/Testcontainers.Tests/Unit/Builders/DockerConfigTest.cs index e7c19496b..e86c41c8f 100644 --- a/tests/Testcontainers.Tests/Unit/Builders/DockerConfigTest.cs +++ b/tests/Testcontainers.Tests/Unit/Builders/DockerConfigTest.cs @@ -46,9 +46,9 @@ public void ReturnsDefaultEndpointWhenDockerContextIsDefault() public void ReturnsConfiguredEndpointWhenDockerContextIsCustomFromPropertiesFile() { // Given - using var context = new ConfigMetaFile("custom", "tcp://127.0.0.1:2375/"); + using var context = new ConfigMetaFile("custom", new Uri("tcp://127.0.0.1:2375/")); - ICustomConfiguration customConfiguration = new PropertiesFileConfiguration(new[] { "docker.context=custom", context.GetDockerConfig() }); + ICustomConfiguration customConfiguration = new PropertiesFileConfiguration(new[] { "docker.context=custom", $"docker.config={context.DockerConfigDirectoryPath}" }); var dockerConfig = new DockerConfig(customConfiguration); // When @@ -62,10 +62,10 @@ public void ReturnsConfiguredEndpointWhenDockerContextIsCustomFromPropertiesFile public void ReturnsConfiguredEndpointWhenDockerContextIsCustomFromConfigFile() { // Given - using var context = new ConfigMetaFile("custom", "tcp://127.0.0.1:2375/"); + using var context = new ConfigMetaFile("custom", new Uri("tcp://127.0.0.1:2375/")); // This test reads the current context JSON node from the Docker config file. - ICustomConfiguration customConfiguration = new PropertiesFileConfiguration(new[] { context.GetDockerConfig() }); + ICustomConfiguration customConfiguration = new PropertiesFileConfiguration(new[] { $"docker.config={context.DockerConfigDirectoryPath}" }); var dockerConfig = new DockerConfig(customConfiguration); // When @@ -83,17 +83,37 @@ public void ReturnsActiveEndpointWhenDockerContextIsUnset() } [Fact] - public void ReturnsNullWhenDockerContextNotFound() + public void ThrowsWhenDockerContextNotFound() { // Given ICustomConfiguration customConfiguration = new PropertiesFileConfiguration(new[] { "docker.context=missing" }); var dockerConfig = new DockerConfig(customConfiguration); // When - var currentEndpoint = dockerConfig.GetCurrentEndpoint(); + var exception = Assert.Throws(() => dockerConfig.GetCurrentEndpoint()); // Then - Assert.Null(currentEndpoint); + Assert.Equal("The Docker context 'missing' does not exist", exception.Message); + Assert.IsType(exception.InnerException); + } + + [Fact] + public void ThrowsWhenDockerConfigEndpointNotFound() + { + // Given + using var context = new ConfigMetaFile("custom"); + + ICustomConfiguration customConfiguration = new PropertiesFileConfiguration(new[] { "docker.context=custom", $"docker.config={context.DockerConfigDirectoryPath}" }); + var dockerConfig = new DockerConfig(customConfiguration); + + // When + var exception = Assert.Throws(() => dockerConfig.GetCurrentEndpoint()); + + // Then + Assert.StartsWith("The Docker host is null in ", exception.Message); + Assert.Contains(context.DockerConfigDirectoryPath, exception.Message); + Assert.EndsWith(" (JSONPath: Endpoints.docker.Host)", exception.Message); + Assert.Null(exception.InnerException); } } @@ -117,9 +137,9 @@ public void ReturnsActiveEndpointWhenDockerHostIsEmpty() public void ReturnsConfiguredEndpointWhenDockerHostIsSet() { // Given - using var context = new ConfigMetaFile("custom", ""); + using var context = new ConfigMetaFile("custom"); - ICustomConfiguration customConfiguration = new PropertiesFileConfiguration(new[] { "docker.host=tcp://127.0.0.1:2375/", context.GetDockerConfig() }); + ICustomConfiguration customConfiguration = new PropertiesFileConfiguration(new[] { "docker.host=tcp://127.0.0.1:2375/", $"docker.config={context.DockerConfigDirectoryPath}" }); var dockerConfig = new DockerConfig(customConfiguration); // When @@ -147,26 +167,32 @@ private sealed class ConfigMetaFile : IDisposable private const string MetaFileJson = "{{\"Name\":\"{0}\",\"Metadata\":{{}},\"Endpoints\":{{\"docker\":{{\"Host\":\"{1}\",\"SkipTLSVerify\":false}}}}}}"; - private readonly string _dockerConfigDirectoryPath; + public string DockerConfigDirectoryPath { get; } - public ConfigMetaFile(string context, string endpoint, [CallerMemberName] string caller = "") + public ConfigMetaFile(string context, [CallerMemberName] string caller = "") { - _dockerConfigDirectoryPath = Path.Combine(TestSession.TempDirectoryPath, caller); - var dockerContextHash = Convert.ToHexString(SHA256.HashData(Encoding.Default.GetBytes(context))).ToLowerInvariant(); - var dockerContextMetaDirectoryPath = Path.Combine(_dockerConfigDirectoryPath, "contexts", "meta", dockerContextHash); - _ = Directory.CreateDirectory(dockerContextMetaDirectoryPath); - File.WriteAllText(Path.Combine(_dockerConfigDirectoryPath, "config.json"), string.Format(ConfigFileJson, context)); - File.WriteAllText(Path.Combine(dockerContextMetaDirectoryPath, "meta.json"), string.Format(MetaFileJson, context, endpoint)); + DockerConfigDirectoryPath = InitializeContext(context, null, caller); } - public string GetDockerConfig() + public ConfigMetaFile(string context, Uri endpoint, [CallerMemberName] string caller = "") { - return "docker.config=" + _dockerConfigDirectoryPath; + DockerConfigDirectoryPath = InitializeContext(context, endpoint, caller); + } + + private static string InitializeContext(string context, Uri endpoint, [CallerMemberName] string caller = "") + { + var dockerConfigDirectoryPath = Path.Combine(TestSession.TempDirectoryPath, caller); + var dockerContextHash = Convert.ToHexString(SHA256.HashData(Encoding.Default.GetBytes(context))).ToLowerInvariant(); + var dockerContextMetaDirectoryPath = Path.Combine(dockerConfigDirectoryPath, "contexts", "meta", dockerContextHash); + _ = Directory.CreateDirectory(dockerContextMetaDirectoryPath); + File.WriteAllText(Path.Combine(dockerConfigDirectoryPath, "config.json"), string.Format(ConfigFileJson, context)); + File.WriteAllText(Path.Combine(dockerContextMetaDirectoryPath, "meta.json"), endpoint == null ? "{}" : string.Format(MetaFileJson, context, endpoint.AbsoluteUri)); + return dockerConfigDirectoryPath; } public void Dispose() { - Directory.Delete(_dockerConfigDirectoryPath, true); + Directory.Delete(DockerConfigDirectoryPath, true); } } }