Skip to content

Commit 1854677

Browse files
0xcedHofmeisterAn
andauthored
feat: Improve error reporting when loading the Docker configuration file (#1263)
Co-authored-by: Andre Hofmeister <[email protected]>
1 parent 33950f1 commit 1854677

File tree

5 files changed

+98
-35
lines changed

5 files changed

+98
-35
lines changed

src/Testcontainers/Builders/DockerConfig.cs

Lines changed: 21 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ public JsonDocument Parse()
7777
/// Executes a command equivalent to <c>docker context inspect --format {{.Endpoints.docker.Host}}</c>.
7878
/// </remarks>
7979
/// A <see cref="Uri" /> representing the current Docker endpoint if available; otherwise, <c>null</c>.
80-
[CanBeNull]
80+
[NotNull]
8181
public Uri GetCurrentEndpoint()
8282
{
8383
const string defaultDockerContext = "default";
@@ -99,16 +99,28 @@ public Uri GetCurrentEndpoint()
9999
var dockerContextHash = BitConverter.ToString(sha256.ComputeHash(Encoding.Default.GetBytes(dockerContext))).Replace("-", string.Empty).ToLowerInvariant();
100100
var metaFilePath = Path.Combine(_dockerConfigDirectoryPath, "contexts", "meta", dockerContextHash, "meta.json");
101101

102-
if (!File.Exists(metaFilePath))
102+
try
103103
{
104-
return null;
104+
using (var metaFileStream = File.OpenRead(metaFilePath))
105+
{
106+
var meta = JsonSerializer.Deserialize(metaFileStream, SourceGenerationContext.Default.DockerContextMeta);
107+
var host = meta.Endpoints?.Docker?.Host;
108+
109+
if (string.IsNullOrEmpty(host))
110+
{
111+
throw new DockerConfigurationException($"The Docker host is null or empty in '{metaFilePath}' (JSONPath: Endpoints.docker.Host).");
112+
}
113+
114+
return new Uri(host.Replace("npipe:////./", "npipe://./"));
115+
}
105116
}
106-
107-
using (var metaFileStream = File.OpenRead(metaFilePath))
117+
catch (Exception e) when (e is DirectoryNotFoundException or FileNotFoundException)
108118
{
109-
var meta = JsonSerializer.Deserialize(metaFileStream, SourceGenerationContext.Default.DockerContextMeta);
110-
var host = meta?.Name == dockerContext ? meta.Endpoints?.Docker?.Host : null;
111-
return string.IsNullOrEmpty(host) ? null : new Uri(host.Replace("npipe:////./", "npipe://./"));
119+
throw new DockerConfigurationException($"The Docker context '{dockerContext}' does not exist.", e);
120+
}
121+
catch (Exception e) when (e is not DockerConfigurationException)
122+
{
123+
throw new DockerConfigurationException($"The Docker context '{dockerContext}' failed to load from '{metaFilePath}'.", e);
112124
}
113125
}
114126
}
@@ -162,15 +174,11 @@ private string GetDockerContext()
162174
internal sealed class DockerContextMeta
163175
{
164176
[JsonConstructor]
165-
public DockerContextMeta(string name, DockerContextMetaEndpoints endpoints)
177+
public DockerContextMeta(DockerContextMetaEndpoints endpoints)
166178
{
167-
Name = name;
168179
Endpoints = endpoints;
169180
}
170181

171-
[JsonPropertyName("Name")]
172-
public string Name { get; }
173-
174182
[JsonPropertyName("Endpoints")]
175183
public DockerContextMetaEndpoints Endpoints { get; }
176184
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
namespace DotNet.Testcontainers.Builders
2+
{
3+
using System;
4+
using JetBrains.Annotations;
5+
6+
/// <summary>
7+
/// Represents an exception that is thrown when the Docker configuration file
8+
/// cannot be read successfully.
9+
/// </summary>
10+
[PublicAPI]
11+
public sealed class DockerConfigurationException : Exception
12+
{
13+
/// <summary>
14+
/// Initializes a new instance of the <see cref="DockerConfigurationException" /> class.
15+
/// </summary>
16+
/// <param name="message">The message that describes the error.</param>
17+
public DockerConfigurationException(string message) : base(message)
18+
{
19+
}
20+
21+
/// <summary>
22+
/// Initializes a new instance of the <see cref="DockerConfigurationException" /> class.
23+
/// </summary>
24+
/// <param name="message">The message that describes the error.</param>
25+
/// <param name="innerException">The exception that is the cause of the current exception.</param>
26+
public DockerConfigurationException(string message, Exception innerException)
27+
: base(message, innerException)
28+
{
29+
}
30+
}
31+
}

src/Testcontainers/Builders/DockerDesktopEndpointAuthenticationProvider.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ internal sealed class DockerDesktopEndpointAuthenticationProvider : RootlessUnix
1515
/// Initializes a new instance of the <see cref="DockerDesktopEndpointAuthenticationProvider" /> class.
1616
/// </summary>
1717
public DockerDesktopEndpointAuthenticationProvider()
18-
: base(DockerConfig.Instance.GetCurrentEndpoint()?.AbsolutePath, GetSocketPathFromHomeDesktopDir(), GetSocketPathFromHomeRunDir())
18+
: base(DockerConfig.Instance.GetCurrentEndpoint())
1919
{
2020
}
2121

src/Testcontainers/Builders/RootlessUnixEndpointAuthenticationProvider.cs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,15 @@ public RootlessUnixEndpointAuthenticationProvider(params string[] socketPaths)
3030
DockerEngine = socketPath == null ? null : new Uri("unix://" + socketPath);
3131
}
3232

33+
/// <summary>
34+
/// Initializes a new instance of the <see cref="RootlessUnixEndpointAuthenticationProvider" /> class.
35+
/// </summary>
36+
/// <param name="dockerEngine">The Docker Engine endpoint.</param>
37+
public RootlessUnixEndpointAuthenticationProvider(Uri dockerEngine)
38+
{
39+
DockerEngine = dockerEngine;
40+
}
41+
3342
/// <summary>
3443
/// Gets the Unix socket Docker Engine endpoint.
3544
/// </summary>

tests/Testcontainers.Tests/Unit/Builders/DockerConfigTest.cs

Lines changed: 36 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -46,9 +46,9 @@ public void ReturnsDefaultEndpointWhenDockerContextIsDefault()
4646
public void ReturnsConfiguredEndpointWhenDockerContextIsCustomFromPropertiesFile()
4747
{
4848
// Given
49-
using var context = new ConfigMetaFile("custom", "tcp://127.0.0.1:2375/");
49+
using var context = new ConfigMetaFile("custom", new Uri("tcp://127.0.0.1:2375/"));
5050

51-
ICustomConfiguration customConfiguration = new PropertiesFileConfiguration(new[] { "docker.context=custom", context.GetDockerConfig() });
51+
ICustomConfiguration customConfiguration = new PropertiesFileConfiguration(new[] { "docker.context=custom", $"docker.config={context.DockerConfigDirectoryPath}" });
5252
var dockerConfig = new DockerConfig(customConfiguration);
5353

5454
// When
@@ -62,10 +62,10 @@ public void ReturnsConfiguredEndpointWhenDockerContextIsCustomFromPropertiesFile
6262
public void ReturnsConfiguredEndpointWhenDockerContextIsCustomFromConfigFile()
6363
{
6464
// Given
65-
using var context = new ConfigMetaFile("custom", "tcp://127.0.0.1:2375/");
65+
using var context = new ConfigMetaFile("custom", new Uri("tcp://127.0.0.1:2375/"));
6666

6767
// This test reads the current context JSON node from the Docker config file.
68-
ICustomConfiguration customConfiguration = new PropertiesFileConfiguration(new[] { context.GetDockerConfig() });
68+
ICustomConfiguration customConfiguration = new PropertiesFileConfiguration(new[] { $"docker.config={context.DockerConfigDirectoryPath}" });
6969
var dockerConfig = new DockerConfig(customConfiguration);
7070

7171
// When
@@ -83,17 +83,37 @@ public void ReturnsActiveEndpointWhenDockerContextIsUnset()
8383
}
8484

8585
[Fact]
86-
public void ReturnsNullWhenDockerContextNotFound()
86+
public void ThrowsWhenDockerContextNotFound()
8787
{
8888
// Given
8989
ICustomConfiguration customConfiguration = new PropertiesFileConfiguration(new[] { "docker.context=missing" });
9090
var dockerConfig = new DockerConfig(customConfiguration);
9191

9292
// When
93-
var currentEndpoint = dockerConfig.GetCurrentEndpoint();
93+
var exception = Assert.Throws<DockerConfigurationException>(() => dockerConfig.GetCurrentEndpoint());
9494

9595
// Then
96-
Assert.Null(currentEndpoint);
96+
Assert.Equal("The Docker context 'missing' does not exist.", exception.Message);
97+
Assert.IsType<DirectoryNotFoundException>(exception.InnerException);
98+
}
99+
100+
[Fact]
101+
public void ThrowsWhenDockerHostNotFound()
102+
{
103+
// Given
104+
using var context = new ConfigMetaFile("custom", null);
105+
106+
ICustomConfiguration customConfiguration = new PropertiesFileConfiguration(new[] { "docker.context=custom", $"docker.config={context.DockerConfigDirectoryPath}" });
107+
var dockerConfig = new DockerConfig(customConfiguration);
108+
109+
// When
110+
var exception = Assert.Throws<DockerConfigurationException>(() => dockerConfig.GetCurrentEndpoint());
111+
112+
// Then
113+
Assert.StartsWith("The Docker host is null or empty in ", exception.Message);
114+
Assert.Contains(context.DockerConfigDirectoryPath, exception.Message);
115+
Assert.EndsWith(" (JSONPath: Endpoints.docker.Host).", exception.Message);
116+
Assert.Null(exception.InnerException);
97117
}
98118
}
99119

@@ -117,9 +137,9 @@ public void ReturnsActiveEndpointWhenDockerHostIsEmpty()
117137
public void ReturnsConfiguredEndpointWhenDockerHostIsSet()
118138
{
119139
// Given
120-
using var context = new ConfigMetaFile("custom", "");
140+
using var context = new ConfigMetaFile("custom", null);
121141

122-
ICustomConfiguration customConfiguration = new PropertiesFileConfiguration(new[] { "docker.host=tcp://127.0.0.1:2375/", context.GetDockerConfig() });
142+
ICustomConfiguration customConfiguration = new PropertiesFileConfiguration(new[] { "docker.host=tcp://127.0.0.1:2375/", $"docker.config={context.DockerConfigDirectoryPath}" });
123143
var dockerConfig = new DockerConfig(customConfiguration);
124144

125145
// When
@@ -147,26 +167,21 @@ private sealed class ConfigMetaFile : IDisposable
147167

148168
private const string MetaFileJson = "{{\"Name\":\"{0}\",\"Metadata\":{{}},\"Endpoints\":{{\"docker\":{{\"Host\":\"{1}\",\"SkipTLSVerify\":false}}}}}}";
149169

150-
private readonly string _dockerConfigDirectoryPath;
151-
152-
public ConfigMetaFile(string context, string endpoint, [CallerMemberName] string caller = "")
170+
public ConfigMetaFile(string context, Uri endpoint, [CallerMemberName] string caller = "")
153171
{
154-
_dockerConfigDirectoryPath = Path.Combine(TestSession.TempDirectoryPath, caller);
172+
DockerConfigDirectoryPath = Path.Combine(TestSession.TempDirectoryPath, caller);
155173
var dockerContextHash = Convert.ToHexString(SHA256.HashData(Encoding.Default.GetBytes(context))).ToLowerInvariant();
156-
var dockerContextMetaDirectoryPath = Path.Combine(_dockerConfigDirectoryPath, "contexts", "meta", dockerContextHash);
174+
var dockerContextMetaDirectoryPath = Path.Combine(DockerConfigDirectoryPath, "contexts", "meta", dockerContextHash);
157175
_ = Directory.CreateDirectory(dockerContextMetaDirectoryPath);
158-
File.WriteAllText(Path.Combine(_dockerConfigDirectoryPath, "config.json"), string.Format(ConfigFileJson, context));
159-
File.WriteAllText(Path.Combine(dockerContextMetaDirectoryPath, "meta.json"), string.Format(MetaFileJson, context, endpoint));
176+
File.WriteAllText(Path.Combine(DockerConfigDirectoryPath, "config.json"), string.Format(ConfigFileJson, context));
177+
File.WriteAllText(Path.Combine(dockerContextMetaDirectoryPath, "meta.json"), endpoint == null ? "{}" : string.Format(MetaFileJson, context, endpoint.AbsoluteUri));
160178
}
161179

162-
public string GetDockerConfig()
163-
{
164-
return "docker.config=" + _dockerConfigDirectoryPath;
165-
}
180+
public string DockerConfigDirectoryPath { get; }
166181

167182
public void Dispose()
168183
{
169-
Directory.Delete(_dockerConfigDirectoryPath, true);
184+
Directory.Delete(DockerConfigDirectoryPath, true);
170185
}
171186
}
172187
}

0 commit comments

Comments
 (0)