diff --git a/README.md b/README.md index f54f8275..d090cf85 100644 --- a/README.md +++ b/README.md @@ -44,37 +44,64 @@ If you intend to use development builds of Docker.DotNet and don't want to compi ## Usage -You can initialize the client like the following: +### Default client (docker CLI–style resolution) + +The parameterless constructor resolves the Docker endpoint the same way the `docker` CLI does. This works on Linux, macOS, and Windows: + +1. **Environment variables** — `DOCKER_HOST` (with `DOCKER_TLS_VERIFY` upgrading `tcp://` to `https://`). +2. **Docker context** — `DOCKER_CONTEXT`, or the `currentContext` field in `~/.docker/config.json` (`%USERPROFILE%\.docker\config.json` on Windows). The endpoint is read from `~/.docker/contexts/meta//meta.json`. `DOCKER_CONFIG` is honored if set. +3. **Platform default** — `unix:///var/run/docker.sock` on Linux/macOS, `npipe://./pipe/docker_engine` on Windows. ```csharp using Docker.DotNet; -DockerClient client = new DockerClientConfiguration( - new Uri("http://ubuntu-docker.cloudapp.net:4243")) +DockerClient client = new DockerClientConfiguration() .CreateClient(); ``` -or to connect to your local [Docker for Windows](https://docs.docker.com/docker-for-windows/) daemon using named pipes or your local [Docker for Mac](https://docs.docker.com/docker-for-mac/) daemon using Unix sockets: + +### Explicit endpoint + +Pass a `Uri` to target a specific endpoint: ```csharp using Docker.DotNet; -DockerClient client = new DockerClientConfiguration() +DockerClient client = new DockerClientConfiguration( + new Uri("http://ubuntu-docker.cloudapp.net:4243")) .CreateClient(); ``` -For a custom endpoint, you can also pass a named pipe or a Unix socket to the `DockerClientConfiguration` constructor. For example: - ```csharp -// Default Docker Engine on Windows -using Docker.DotNet; +// Docker Engine on Windows DockerClient client = new DockerClientConfiguration( new Uri("npipe://./pipe/docker_engine")) .CreateClient(); -// Default Docker Engine on Linux -using Docker.DotNet; +// Docker Engine on Linux/macOS DockerClient client = new DockerClientConfiguration( new Uri("unix:///var/run/docker.sock")) .CreateClient(); ``` +### Specific Docker context by name + +Use `FromContext` to target a context by name (reads `~/.docker/contexts/meta//meta.json`): + +```csharp +using Docker.DotNet; +DockerClient client = DockerClientConfiguration + .FromContext("desktop-linux") + .CreateClient(); +``` + +You can combine it with credentials (TLS, basic auth, etc.) the same way as the regular constructor: + +```csharp +var credentials = new CertificateCredentials(new X509Certificate2("cert.pfx", "password")); +DockerClient client = DockerClientConfiguration + .FromContext("my-remote", credentials) + .CreateClient(); +``` + +> **Note:** `ssh://` endpoints (from SSH-based Docker contexts) are not supported. To connect to a remote daemon over SSH, set up an SSH tunnel and point `DOCKER_HOST` (or a context) at the forwarded socket. + #### Example: List containers ```csharp diff --git a/src/Docker.DotNet/DockerClient.cs b/src/Docker.DotNet/DockerClient.cs index f04e24ee..b56155f4 100644 --- a/src/Docker.DotNet/DockerClient.cs +++ b/src/Docker.DotNet/DockerClient.cs @@ -114,6 +114,11 @@ await sock.ConnectAsync(new Microsoft.Net.Http.Client.UnixDomainSocketEndPoint(p uri = new UriBuilder("http", uri.Segments.Last()).Uri; break; + case "ssh": + throw new NotSupportedException( + $"SSH endpoints are not supported by Docker.DotNet ({configuration.EndpointBaseUri}). " + + "Use a tcp/https/unix/npipe endpoint, or set up an SSH tunnel and point DOCKER_HOST at the forwarded socket."); + default: throw new Exception($"Unknown URL scheme {configuration.EndpointBaseUri.Scheme}"); } diff --git a/src/Docker.DotNet/DockerClientConfiguration.cs b/src/Docker.DotNet/DockerClientConfiguration.cs index 39937a7c..34b15dc6 100644 --- a/src/Docker.DotNet/DockerClientConfiguration.cs +++ b/src/Docker.DotNet/DockerClientConfiguration.cs @@ -1,19 +1,43 @@ using System; using System.Collections.Generic; -using System.Runtime.InteropServices; using System.Threading; namespace Docker.DotNet { public class DockerClientConfiguration : IDisposable { + /// + /// Creates a configuration whose endpoint is resolved like the docker CLI: + /// the DOCKER_HOST environment variable first, then the active Docker + /// context (DOCKER_CONTEXT or currentContext in + /// ~/.docker/config.json), then the platform default socket. + /// public DockerClientConfiguration( Credentials credentials = null, TimeSpan defaultTimeout = default, TimeSpan namedPipeConnectTimeout = default, IReadOnlyDictionary defaultHttpRequestHeaders = null) - : this(GetLocalDockerEndpoint(), credentials, defaultTimeout, namedPipeConnectTimeout, defaultHttpRequestHeaders) + : this(DockerContextResolver.Resolve(), credentials, defaultTimeout, namedPipeConnectTimeout, defaultHttpRequestHeaders) + { + } + + /// + /// Creates a configuration that targets the named Docker context, reading + /// its endpoint from ~/.docker/contexts/meta/<sha256(name)>/meta.json. + /// + public static DockerClientConfiguration FromContext( + string contextName, + Credentials credentials = null, + TimeSpan defaultTimeout = default, + TimeSpan namedPipeConnectTimeout = default, + IReadOnlyDictionary defaultHttpRequestHeaders = null) { + return new DockerClientConfiguration( + DockerContextResolver.ResolveForContext(contextName), + credentials, + defaultTimeout, + namedPipeConnectTimeout, + defaultHttpRequestHeaders); } public DockerClientConfiguration( @@ -62,11 +86,5 @@ public void Dispose() { Credentials.Dispose(); } - - private static Uri GetLocalDockerEndpoint() - { - var isWindows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows); - return isWindows ? new Uri("npipe://./pipe/docker_engine") : new Uri("unix:/var/run/docker.sock"); - } } } \ No newline at end of file diff --git a/src/Docker.DotNet/DockerContextResolver.cs b/src/Docker.DotNet/DockerContextResolver.cs new file mode 100644 index 00000000..4bf4f89c --- /dev/null +++ b/src/Docker.DotNet/DockerContextResolver.cs @@ -0,0 +1,210 @@ +using System; +using System.IO; +using System.Runtime.InteropServices; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; + +namespace Docker.DotNet +{ + /// + /// Resolves the Docker daemon endpoint the same way the docker CLI does: + /// environment variables first, then the current Docker context, then the + /// platform default socket. Works on Linux, macOS, and Windows. + /// + internal static class DockerContextResolver + { + private const string DefaultContextName = "default"; + private const string DockerEndpointKey = "docker"; + + public static Uri Resolve() + { + var host = Environment.GetEnvironmentVariable("DOCKER_HOST"); + if (!string.IsNullOrEmpty(host)) + { + return BuildHostUri(host, IsTlsVerifyEnabled()); + } + + var contextName = Environment.GetEnvironmentVariable("DOCKER_CONTEXT"); + if (string.IsNullOrEmpty(contextName)) + { + contextName = TryReadCurrentContext(); + } + + if (!string.IsNullOrEmpty(contextName) && !string.Equals(contextName, DefaultContextName, StringComparison.Ordinal)) + { + var contextHost = TryReadContextHost(contextName); + if (!string.IsNullOrEmpty(contextHost)) + { + return BuildHostUri(contextHost, IsTlsVerifyEnabled()); + } + } + + return GetPlatformDefault(); + } + + public static Uri ResolveForContext(string contextName) + { + if (string.IsNullOrEmpty(contextName)) + { + throw new ArgumentException("Context name must be provided", nameof(contextName)); + } + + if (string.Equals(contextName, DefaultContextName, StringComparison.Ordinal)) + { + return GetPlatformDefault(); + } + + var host = TryReadContextHost(contextName); + if (string.IsNullOrEmpty(host)) + { + throw new InvalidOperationException($"Docker context '{contextName}' was not found under '{GetContextsMetaDirectory()}'."); + } + + return BuildHostUri(host, tlsVerify: false); + } + + private static bool IsTlsVerifyEnabled() + { + var value = Environment.GetEnvironmentVariable("DOCKER_TLS_VERIFY"); + return !string.IsNullOrEmpty(value); + } + + private static Uri BuildHostUri(string host, bool tlsVerify) + { + if (tlsVerify && host.StartsWith("tcp://", StringComparison.OrdinalIgnoreCase)) + { + host = "https://" + host.Substring("tcp://".Length); + } + + host = NormalizeNpipe(host); + + return new Uri(host); + } + + // Docker stores Windows named-pipe endpoints in Go's native form + // (npipe:////./pipe/), but DockerClient's npipe handler expects + // npipe://./pipe/. Collapse the former (and the localhost variant) + // to the latter so resolved contexts are usable on Windows. + private static string NormalizeNpipe(string host) + { + const string scheme = "npipe://"; + if (!host.StartsWith(scheme, StringComparison.OrdinalIgnoreCase)) + { + return host; + } + + var pipe = host.Substring(scheme.Length).TrimStart('/'); + if (pipe.StartsWith("./", StringComparison.Ordinal)) + { + pipe = pipe.Substring("./".Length); + } + else if (pipe.StartsWith("localhost/", StringComparison.OrdinalIgnoreCase)) + { + pipe = pipe.Substring("localhost/".Length); + } + + return scheme + "./" + pipe; + } + + private static string TryReadCurrentContext() + { + var configPath = Path.Combine(GetDockerConfigDirectory(), "config.json"); + if (!File.Exists(configPath)) + { + return null; + } + + try + { + using var stream = File.OpenRead(configPath); + using var document = JsonDocument.Parse(stream); + if (document.RootElement.TryGetProperty("currentContext", out var element) && element.ValueKind == JsonValueKind.String) + { + return element.GetString(); + } + } + catch (JsonException) + { + } + catch (IOException) + { + } + + return null; + } + + private static string TryReadContextHost(string contextName) + { + var metaPath = Path.Combine(GetContextsMetaDirectory(), Sha256Hex(contextName), "meta.json"); + if (!File.Exists(metaPath)) + { + return null; + } + + try + { + using var stream = File.OpenRead(metaPath); + using var document = JsonDocument.Parse(stream); + if (!document.RootElement.TryGetProperty("Endpoints", out var endpoints) || endpoints.ValueKind != JsonValueKind.Object) + { + return null; + } + + if (!endpoints.TryGetProperty(DockerEndpointKey, out var dockerEndpoint) || dockerEndpoint.ValueKind != JsonValueKind.Object) + { + return null; + } + + if (dockerEndpoint.TryGetProperty("Host", out var hostElement) && hostElement.ValueKind == JsonValueKind.String) + { + return hostElement.GetString(); + } + } + catch (JsonException) + { + } + catch (IOException) + { + } + + return null; + } + + private static string GetDockerConfigDirectory() + { + var dockerConfig = Environment.GetEnvironmentVariable("DOCKER_CONFIG"); + if (!string.IsNullOrEmpty(dockerConfig)) + { + return dockerConfig; + } + + return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".docker"); + } + + private static string GetContextsMetaDirectory() + { + return Path.Combine(GetDockerConfigDirectory(), "contexts", "meta"); + } + + private static string Sha256Hex(string value) + { + using var sha = SHA256.Create(); + var bytes = sha.ComputeHash(Encoding.UTF8.GetBytes(value)); + var builder = new StringBuilder(bytes.Length * 2); + foreach (var b in bytes) + { + builder.Append(b.ToString("x2")); + } + + return builder.ToString(); + } + + private static Uri GetPlatformDefault() + { + return RuntimeInformation.IsOSPlatform(OSPlatform.Windows) + ? new Uri("npipe://./pipe/docker_engine") + : new Uri("unix:/var/run/docker.sock"); + } + } +} diff --git a/test/Docker.DotNet.Tests/DockerContextResolverTests.cs b/test/Docker.DotNet.Tests/DockerContextResolverTests.cs new file mode 100644 index 00000000..7c3a7dd4 --- /dev/null +++ b/test/Docker.DotNet.Tests/DockerContextResolverTests.cs @@ -0,0 +1,220 @@ +using System; +using System.IO; +using System.Runtime.InteropServices; +using System.Security.Cryptography; +using System.Text; +using Xunit; + +// These tests mutate process-wide DOCKER_* environment variables, so they must +// not run in parallel with the daemon-backed integration tests (whose fixture +// reads those variables when it constructs a DockerClientConfiguration). +[assembly: CollectionBehavior(DisableTestParallelization = true)] + +namespace Docker.DotNet.Tests +{ + public class DockerContextResolverTests + { + private static readonly string PlatformDefault = + RuntimeInformation.IsOSPlatform(OSPlatform.Windows) + ? "npipe://./pipe/docker_engine" + : "unix:/var/run/docker.sock"; + + [Fact] + public void Default_constructor_honors_DOCKER_HOST() + { + using var env = new DockerEnvironment(); + env.Set("DOCKER_HOST", "tcp://1.2.3.4:2375"); + + using var configuration = new DockerClientConfiguration(); + + Assert.Equal(new Uri("tcp://1.2.3.4:2375"), configuration.EndpointBaseUri); + } + + [Fact] + public void Default_constructor_upgrades_tcp_to_https_when_DOCKER_TLS_VERIFY_set() + { + using var env = new DockerEnvironment(); + env.Set("DOCKER_HOST", "tcp://1.2.3.4:2376"); + env.Set("DOCKER_TLS_VERIFY", "1"); + + using var configuration = new DockerClientConfiguration(); + + Assert.Equal(new Uri("https://1.2.3.4:2376"), configuration.EndpointBaseUri); + } + + [Fact] + public void Default_constructor_falls_back_to_platform_default() + { + using var env = new DockerEnvironment(); + // Empty config directory, no DOCKER_HOST/DOCKER_CONTEXT: nothing to resolve. + + using var configuration = new DockerClientConfiguration(); + + Assert.Equal(new Uri(PlatformDefault), configuration.EndpointBaseUri); + } + + [Fact] + public void Default_constructor_resolves_currentContext_from_config_json() + { + using var env = new DockerEnvironment(); + env.WriteContext("remote", "tcp://10.0.0.5:2375"); + env.WriteCurrentContext("remote"); + + using var configuration = new DockerClientConfiguration(); + + Assert.Equal(new Uri("tcp://10.0.0.5:2375"), configuration.EndpointBaseUri); + } + + [Fact] + public void Default_constructor_prefers_DOCKER_CONTEXT_over_config_json() + { + using var env = new DockerEnvironment(); + env.WriteContext("from-config", "tcp://10.0.0.5:2375"); + env.WriteCurrentContext("from-config"); + env.WriteContext("from-env", "tcp://10.0.0.9:2375"); + env.Set("DOCKER_CONTEXT", "from-env"); + + using var configuration = new DockerClientConfiguration(); + + Assert.Equal(new Uri("tcp://10.0.0.9:2375"), configuration.EndpointBaseUri); + } + + [Fact] + public void Default_constructor_normalizes_windows_named_pipe_context() + { + // Docker stores Windows pipe endpoints in Go's native form. The + // resolved endpoint must be normalized to the form DockerClient's + // npipe handler accepts (npipe://./pipe/), otherwise creating a + // client throws "is not a valid npipe URI". + using var env = new DockerEnvironment(); + env.WriteContext("desktop-linux", "npipe:////./pipe/dockerDesktopLinuxEngine"); + env.WriteCurrentContext("desktop-linux"); + + using var configuration = new DockerClientConfiguration(); + + Assert.Equal(new Uri("npipe://./pipe/dockerDesktopLinuxEngine"), configuration.EndpointBaseUri); + + // Must not throw - this is the failure the normalization fixes. + using var client = configuration.CreateClient(); + } + + [Fact] + public void FromContext_resolves_named_context() + { + using var env = new DockerEnvironment(); + env.WriteContext("remote", "tcp://10.0.0.5:2375"); + + using var configuration = DockerClientConfiguration.FromContext("remote"); + + Assert.Equal(new Uri("tcp://10.0.0.5:2375"), configuration.EndpointBaseUri); + } + + [Fact] + public void FromContext_default_returns_platform_default() + { + using var env = new DockerEnvironment(); + + using var configuration = DockerClientConfiguration.FromContext("default"); + + Assert.Equal(new Uri(PlatformDefault), configuration.EndpointBaseUri); + } + + [Fact] + public void FromContext_unknown_context_throws() + { + using var env = new DockerEnvironment(); + + Assert.Throws(() => DockerClientConfiguration.FromContext("does-not-exist")); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + public void FromContext_requires_a_context_name(string contextName) + { + using var env = new DockerEnvironment(); + + Assert.Throws(() => DockerClientConfiguration.FromContext(contextName)); + } + + /// + /// Snapshots and clears the DOCKER_* variables the resolver reads, points + /// DOCKER_CONFIG at an isolated temp directory, and restores everything on + /// dispose so each test sees a clean, deterministic environment. + /// + private sealed class DockerEnvironment : IDisposable + { + private static readonly string[] Names = + { + "DOCKER_HOST", "DOCKER_TLS_VERIFY", "DOCKER_CONTEXT", "DOCKER_CONFIG" + }; + + private readonly (string Name, string Value)[] _saved; + private readonly string _configDirectory; + + public DockerEnvironment() + { + _saved = Array.ConvertAll(Names, name => (name, Environment.GetEnvironmentVariable(name))); + + foreach (var name in Names) + { + Environment.SetEnvironmentVariable(name, null); + } + + _configDirectory = Path.Combine(Path.GetTempPath(), "docker.dotnet.tests", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_configDirectory); + Environment.SetEnvironmentVariable("DOCKER_CONFIG", _configDirectory); + } + + public void Set(string name, string value) + { + Environment.SetEnvironmentVariable(name, value); + } + + public void WriteCurrentContext(string contextName) + { + File.WriteAllText( + Path.Combine(_configDirectory, "config.json"), + "{\"currentContext\":\"" + contextName + "\"}"); + } + + public void WriteContext(string contextName, string host) + { + var metaDirectory = Path.Combine(_configDirectory, "contexts", "meta", Sha256Hex(contextName)); + Directory.CreateDirectory(metaDirectory); + File.WriteAllText( + Path.Combine(metaDirectory, "meta.json"), + "{\"Name\":\"" + contextName + "\",\"Endpoints\":{\"docker\":{\"Host\":\"" + host + "\"}}}"); + } + + public void Dispose() + { + foreach (var (name, value) in _saved) + { + Environment.SetEnvironmentVariable(name, value); + } + + try + { + Directory.Delete(_configDirectory, recursive: true); + } + catch (IOException) + { + } + } + + private static string Sha256Hex(string value) + { + using var sha = SHA256.Create(); + var bytes = sha.ComputeHash(Encoding.UTF8.GetBytes(value)); + var builder = new StringBuilder(bytes.Length * 2); + foreach (var b in bytes) + { + builder.Append(b.ToString("x2")); + } + + return builder.ToString(); + } + } + } +}