diff --git a/README.md b/README.md index dea7f7bb..bfc21af7 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,31 @@ irb(main):004:0> Docker.options => {} ``` +#### Endpoint resolution + +When `Docker.url` is not explicitly set, the gem resolves the endpoint the same way the `docker` CLI does, in this order: + +1. `DOCKER_URL` environment variable (gem-specific, takes precedence over `DOCKER_HOST`). +2. `DOCKER_HOST` environment variable. +3. The current docker CLI context: + - `DOCKER_CONTEXT` environment variable, if set, names the context. + - Otherwise the `currentContext` field of `$DOCKER_CONFIG/config.json` (defaults to `~/.docker/config.json`) is used. + - The context's `Endpoints.docker.Host` is read from `$DOCKER_CONFIG/contexts/meta//meta.json`. + - A context named `default`, a missing config file, or a missing meta file all fall through to the next step. +4. The default socket: `unix:///var/run/docker.sock` on Linux/macOS, or `npipe:////./pipe/docker_engine` on Windows. + +Set `DOCKER_API_SKIP_CONTEXT=1` to disable step 3 entirely and keep the pre-context behavior (env vars only, then default socket). + +#### Windows named pipes (npipe) + +On Windows the default Docker endpoint is the named pipe `\\.\pipe\docker_engine`, expressed as a URL like `npipe:////./pipe/docker_engine`. The gem talks to it directly through the Win32 API (via the `ffi` gem), so no extra setup is needed when Docker Desktop is running. You can also point the gem at a non-default pipe: + +```ruby +Docker.url = 'npipe:////./pipe/some_other_engine' +``` + +Only the endpoint URL is read from the context; TLS settings still come from `DOCKER_CERT_PATH` / `DOCKER_SSL_VERIFY` (see [SSL](#ssl) below) or from `Docker.options`. + ### SSL When running docker using SSL, setting the DOCKER_CERT_PATH will configure docker-api to use SSL. diff --git a/docker-api.gemspec b/docker-api.gemspec index 1a131c76..02dd9d5d 100644 --- a/docker-api.gemspec +++ b/docker-api.gemspec @@ -14,6 +14,8 @@ Gem::Specification.new do |gem| gem.version = Docker::VERSION gem.add_dependency 'excon', '>= 0.64.0' gem.add_dependency 'multi_json' + # Required only on Windows to talk to the Docker engine over npipe. + gem.add_dependency 'ffi' gem.add_development_dependency 'rake' gem.add_development_dependency 'rspec', '~> 3.0' gem.add_development_dependency 'rspec-its' diff --git a/lib/docker.rb b/lib/docker.rb index 973e0d5e..a0857238 100644 --- a/lib/docker.rb +++ b/lib/docker.rb @@ -5,6 +5,7 @@ require 'excon' require 'tempfile' require 'base64' +require 'digest' require 'find' require 'rubygems/package' require 'uri' @@ -39,13 +40,51 @@ module Docker require 'docker/rake_task' if defined?(Rake::Task) def default_socket_url - 'unix:///var/run/docker.sock' + if Gem.win_platform? + 'npipe:////./pipe/docker_engine' + else + 'unix:///var/run/docker.sock' + end end def env_url ENV['DOCKER_URL'] || ENV['DOCKER_HOST'] end + # Resolve the docker endpoint from the docker CLI config, mirroring the CLI + # lookup: DOCKER_CONTEXT env first, then `currentContext` from config.json + # under $DOCKER_CONFIG (or ~/.docker). Returns nil when no usable context is + # found or when disabled via DOCKER_API_SKIP_CONTEXT=1. + def context_url + return nil if ENV['DOCKER_API_SKIP_CONTEXT'] == '1' + + name = ENV['DOCKER_CONTEXT'] || current_context_from_config + return nil if name.nil? || name.empty? || name == 'default' + + endpoint_from_context(name) + end + + def config_dir + ENV['DOCKER_CONFIG'] || File.join(Dir.home, '.docker') + end + + def current_context_from_config + config_path = File.join(config_dir, 'config.json') + return nil unless File.exist?(config_path) + MultiJson.load(File.read(config_path))['currentContext'] + rescue StandardError + nil + end + + def endpoint_from_context(name) + id = Digest::SHA256.hexdigest(name) + meta_path = File.join(config_dir, 'contexts', 'meta', id, 'meta.json') + return nil unless File.exist?(meta_path) + MultiJson.load(File.read(meta_path)).dig('Endpoints', 'docker', 'Host') + rescue StandardError + nil + end + def env_options if cert_path = ENV['DOCKER_CERT_PATH'] { @@ -70,7 +109,7 @@ def ssl_options end def url - @url ||= env_url || default_socket_url + @url ||= env_url || context_url || default_socket_url # docker uses a default notation tcp:// which means tcp://localhost:2375 if @url == 'tcp://' @url = 'tcp://localhost:2375' @@ -141,7 +180,9 @@ def authenticate!(options = {}, connection = self.connection) raise Docker::Error::AuthenticationError end - module_function :default_socket_url, :env_url, :url, :url=, :env_options, + module_function :default_socket_url, :env_url, :context_url, :config_dir, + :current_context_from_config, :endpoint_from_context, + :url, :url=, :env_options, :options, :options=, :creds, :creds=, :logger, :logger=, :connection, :reset!, :reset_connection!, :version, :info, :ping, :podman?, :rootless?, :authenticate!, :ssl_options diff --git a/lib/docker/connection.rb b/lib/docker/connection.rb index d678d243..ea3530d4 100644 --- a/lib/docker/connection.rb +++ b/lib/docker/connection.rb @@ -23,6 +23,9 @@ def initialize(url, opts) uri = URI.parse(url) if uri.scheme == "unix" @url, @options = 'unix:///', {:socket => uri.path}.merge(opts) + elsif uri.scheme == "npipe" + require 'docker/excon_npipe' + @url, @options = 'npipe:///', {:socket => uri.path}.merge(opts) elsif uri.scheme =~ /^(https?|tcp)$/ @url, @options = url, opts else diff --git a/lib/docker/excon_npipe.rb b/lib/docker/excon_npipe.rb new file mode 100644 index 00000000..60ca25bd --- /dev/null +++ b/lib/docker/excon_npipe.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +require 'docker/npipe_socket' + +# Teaches Excon::Connection to recognize the `npipe://` scheme by dispatching +# to Docker::NPipeSocket and computing a unix-style socket key. Loaded only +# when a Docker::Connection is constructed with an `npipe://` URL. +module Docker::ExconNPipe + NPIPE = 'npipe' + + def initialize(params = {}) + super + if @data[:scheme] == NPIPE + @socket_key = "#{@data[:scheme]}://#{@data[:socket]}" + end + end + + def socket(datum = @data) + if datum[:scheme] == NPIPE + sockets[@socket_key] ||= Docker::NPipeSocket.new(datum) + else + super + end + end +end + +Excon::Connection.prepend(Docker::ExconNPipe) unless Excon::Connection.include?(Docker::ExconNPipe) diff --git a/lib/docker/npipe_socket.rb b/lib/docker/npipe_socket.rb new file mode 100644 index 00000000..3cbddd60 --- /dev/null +++ b/lib/docker/npipe_socket.rb @@ -0,0 +1,171 @@ +# frozen_string_literal: true + +require 'ffi' + +# Excon-compatible socket backed by a Windows named pipe. Used to talk to the +# Docker engine via npipe:////./pipe/docker_engine on Windows. Implements the +# subset of the Excon::Socket interface that Excon::Connection actually drives. +class Docker::NPipeSocket < Excon::Socket + module Win32 + extend FFI::Library + + ffi_lib 'kernel32' + ffi_convention :stdcall + + GENERIC_READ = 0x80000000 + GENERIC_WRITE = 0x40000000 + OPEN_EXISTING = 3 + INVALID_HANDLE_VALUE = FFI::Pointer.new(-1).address + + ERROR_BROKEN_PIPE = 109 + ERROR_PIPE_NOT_CONNECTED = 233 + + NMPWAIT_USE_DEFAULT_WAIT = 0 + + attach_function :CreateFileA, [:string, :ulong, :ulong, :pointer, :ulong, :ulong, :pointer], :pointer, save_errno: true + attach_function :ReadFile, [:pointer, :pointer, :ulong, :pointer, :pointer], :int, save_errno: true + attach_function :WriteFile, [:pointer, :pointer, :ulong, :pointer, :pointer], :int, save_errno: true + attach_function :CloseHandle, [:pointer], :int + attach_function :WaitNamedPipeA, [:string, :ulong], :int, save_errno: true + end + + def initialize(data = {}) + # Windows named pipe IO from Ruby doesn't behave well in non-blocking mode, + # so always use the blocking read/write paths from Excon::Socket. + super(data.merge(nonblock: false)) + end + + def read(max_length = nil) + return '' if @eof && max_length.nil? + return nil if @eof + + buffer_size = max_length || @data[:chunk_size] || 16_384 + buf = FFI::MemoryPointer.new(:char, buffer_size) + bytes_read = FFI::MemoryPointer.new(:ulong) + + result = with_timeout(:read_timeout) do + Win32.ReadFile(@handle, buf, buffer_size, bytes_read, nil) + end + + n = bytes_read.read_ulong + if result == 0 + err = FFI.errno + if err == Win32::ERROR_BROKEN_PIPE || err == Win32::ERROR_PIPE_NOT_CONNECTED + @eof = true + return max_length ? nil : '' + end + raise Excon::Errors::SocketError.new(IOError.new("ReadFile failed (err=#{err})")) + end + + if n.zero? + @eof = true + return max_length ? nil : '' + end + + buf.read_string(n) + end + + def readline + line = String.new + until (idx = line.index("\n")) + chunk = read(1) + raise EOFError if chunk.nil? || chunk.empty? + line << chunk + end + line + end + + def write(data) + data = data.b + offset = 0 + total = data.bytesize + written_out = FFI::MemoryPointer.new(:ulong) + buf = FFI::MemoryPointer.new(:char, total) + buf.write_string(data) + + while offset < total + result = with_timeout(:write_timeout) do + Win32.WriteFile( + @handle, + buf + offset, + total - offset, + written_out, + nil + ) + end + + if result == 0 + err = FFI.errno + raise Excon::Errors::SocketError.new(IOError.new("WriteFile failed (err=#{err})")) + end + + n = written_out.read_ulong + raise Excon::Errors::SocketError.new(IOError.new('WriteFile wrote 0 bytes')) if n.zero? + offset += n + end + + total + end + + def close + return if @handle.nil? || @handle.address == Win32::INVALID_HANDLE_VALUE + Win32.CloseHandle(@handle) + @handle = nil + end + + def local_address + nil + end + + def local_port + nil + end + + private + + def connect + pipe_path = self.class.normalize_pipe_path(@data[:socket] || @data[:path]) + raise ArgumentError, 'npipe socket requires a pipe path' if pipe_path.nil? || pipe_path.empty? + + # Wait for the pipe to be available if a previous connection holds it. + Win32.WaitNamedPipeA(pipe_path, Win32::NMPWAIT_USE_DEFAULT_WAIT) + + @handle = Win32.CreateFileA( + pipe_path, + Win32::GENERIC_READ | Win32::GENERIC_WRITE, + 0, + nil, + Win32::OPEN_EXISTING, + 0, + nil + ) + + if @handle.nil? || @handle.address == Win32::INVALID_HANDLE_VALUE + err = FFI.errno + raise Excon::Errors::SocketError.new( + IOError.new("Could not open npipe '#{pipe_path}' (err=#{err})") + ) + end + end + + def with_timeout(kind) + timeout = @data[kind] + return yield if timeout.nil? || timeout.zero? + + Timeout.timeout(timeout) { yield } + rescue Timeout::Error + raise Excon::Errors::Timeout.new("#{kind} reached") + end + + # Accepts any of: '\\.\pipe\docker_engine', '//./pipe/docker_engine', + # '////./pipe/docker_engine', '/pipe/docker_engine'. Returns the canonical + # Windows form '\\.\pipe\'. + def self.normalize_pipe_path(path) + return nil if path.nil? || path.empty? + + path = path.tr('/', "\\") + path = path.sub(/\A\\+/, '') # drop any leading backslashes + path = path.sub(/\A\.\\/, '') # drop leading ".\" if present + "\\\\.\\#{path}" + end +end diff --git a/spec/docker/connection_spec.rb b/spec/docker/connection_spec.rb index 86291298..bc7fe57c 100644 --- a/spec/docker/connection_spec.rb +++ b/spec/docker/connection_spec.rb @@ -22,7 +22,7 @@ context 'when the first argument is a String' do context 'and the url is a unix socket' do - let(:url) { ::Docker.env_url || ::Docker.default_socket_url } + let(:url) { 'unix:///var/run/docker.sock' } it 'sets the socket path in the options' do expect(subject.url).to eq('unix:///') @@ -30,6 +30,15 @@ end end + context 'and the url is a Windows named pipe' do + let(:url) { 'npipe:////./pipe/docker_engine' } + + it 'extracts the pipe path into :socket and normalizes the scheme url' do + expect(subject.url).to eq('npipe:///') + expect(subject.options).to include(:socket => '//./pipe/docker_engine') + end + end + context 'but the second argument is not a Hash' do let(:options) { :lol_not_a_hash } diff --git a/spec/docker/npipe_socket_spec.rb b/spec/docker/npipe_socket_spec.rb new file mode 100644 index 00000000..6f79d0ba --- /dev/null +++ b/spec/docker/npipe_socket_spec.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +require 'spec_helper' + +# Skip on non-Windows since the file itself requires `ffi` and Win32 libs. +if Gem.win_platform? + require 'docker/npipe_socket' + + describe Docker::NPipeSocket do + describe '.normalize_pipe_path' do + subject { described_class.method(:normalize_pipe_path) } + + it 'returns nil for nil input' do + expect(subject.call(nil)).to be_nil + end + + it 'returns nil for empty input' do + expect(subject.call('')).to be_nil + end + + it 'normalizes a URI-style path' do + expect(subject.call('//./pipe/docker_engine')).to eq('\\\\.\\pipe\\docker_engine') + end + + it 'normalizes a path with extra leading slashes (from npipe://// URL)' do + expect(subject.call('////./pipe/docker_engine')).to eq('\\\\.\\pipe\\docker_engine') + end + + it 'is a no-op for a canonical Windows path' do + expect(subject.call('\\\\.\\pipe\\docker_engine')).to eq('\\\\.\\pipe\\docker_engine') + end + + it 'prefixes \\\\.\\ when missing' do + expect(subject.call('/pipe/docker_engine')).to eq('\\\\.\\pipe\\docker_engine') + end + end + end +end diff --git a/spec/docker_spec.rb b/spec/docker_spec.rb index b6656d13..61512f3c 100644 --- a/spec/docker_spec.rb +++ b/spec/docker_spec.rb @@ -15,6 +15,8 @@ allow(ENV).to receive(:[]).with('DOCKER_URL').and_return(nil) allow(ENV).to receive(:[]).with('DOCKER_HOST').and_return(nil) allow(ENV).to receive(:[]).with('DOCKER_CERT_PATH').and_return(nil) + allow(Docker).to receive(:context_url).and_return(nil) + allow(Gem).to receive(:win_platform?).and_return(false) Docker.reset! end after { Docker.reset! } @@ -24,6 +26,21 @@ its(:connection) { should be_a Docker::Connection } end + context "when on Windows with no DOCKER_* ENV variables set" do + before do + allow(ENV).to receive(:[]).with('DOCKER_URL').and_return(nil) + allow(ENV).to receive(:[]).with('DOCKER_HOST').and_return(nil) + allow(ENV).to receive(:[]).with('DOCKER_CERT_PATH').and_return(nil) + allow(Docker).to receive(:context_url).and_return(nil) + allow(Gem).to receive(:win_platform?).and_return(true) + Docker.reset! + end + after { Docker.reset! } + + its(:url) { should == 'npipe:////./pipe/docker_engine' } + its(:connection) { should be_a Docker::Connection } + end + context "when the DOCKER_* ENV variables are set" do before do allow(ENV).to receive(:[]).with('DOCKER_URL') @@ -136,6 +153,110 @@ end + context 'docker CLI context resolution' do + let(:config_dir) { '/tmp/fake-docker-config' } + let(:config_path) { File.join(config_dir, 'config.json') } + let(:context_name) { 'remote' } + let(:context_id) { Digest::SHA256.hexdigest(context_name) } + let(:meta_path) { File.join(config_dir, 'contexts', 'meta', context_id, 'meta.json') } + let(:context_host) { 'tcp://remote.example.com:2376' } + + before do + allow(ENV).to receive(:[]).with('DOCKER_URL').and_return(nil) + allow(ENV).to receive(:[]).with('DOCKER_HOST').and_return(nil) + allow(ENV).to receive(:[]).with('DOCKER_CERT_PATH').and_return(nil) + allow(ENV).to receive(:[]).with('DOCKER_API_SKIP_CONTEXT').and_return(nil) + allow(ENV).to receive(:[]).with('DOCKER_CONTEXT').and_return(nil) + allow(ENV).to receive(:[]).with('DOCKER_CONFIG').and_return(config_dir) + Docker.reset! + end + after { Docker.reset! } + + context 'when currentContext in config.json points to a non-default context' do + before do + allow(File).to receive(:exist?).with(config_path).and_return(true) + allow(File).to receive(:read).with(config_path) + .and_return(MultiJson.dump('currentContext' => context_name)) + allow(File).to receive(:exist?).with(meta_path).and_return(true) + allow(File).to receive(:read).with(meta_path) + .and_return(MultiJson.dump('Endpoints' => { 'docker' => { 'Host' => context_host } })) + end + + its(:url) { should == context_host } + end + + context 'when DOCKER_CONTEXT env overrides currentContext' do + let(:context_name) { 'staging' } + let(:other_host) { 'tcp://staging.example.com:2376' } + + before do + allow(ENV).to receive(:[]).with('DOCKER_CONTEXT').and_return(context_name) + allow(File).to receive(:exist?).with(config_path).and_return(true) + allow(File).to receive(:read).with(config_path) + .and_return(MultiJson.dump('currentContext' => 'something-else')) + allow(File).to receive(:exist?).with(meta_path).and_return(true) + allow(File).to receive(:read).with(meta_path) + .and_return(MultiJson.dump('Endpoints' => { 'docker' => { 'Host' => other_host } })) + end + + its(:url) { should == other_host } + end + + context 'when currentContext is "default"' do + before do + allow(File).to receive(:exist?).with(config_path).and_return(true) + allow(File).to receive(:read).with(config_path) + .and_return(MultiJson.dump('currentContext' => 'default')) + end + + its(:url) { should == 'unix:///var/run/docker.sock' } + end + + context 'when config.json is absent' do + before do + allow(File).to receive(:exist?).with(config_path).and_return(false) + end + + its(:url) { should == 'unix:///var/run/docker.sock' } + end + + context 'when context meta is absent' do + before do + allow(File).to receive(:exist?).with(config_path).and_return(true) + allow(File).to receive(:read).with(config_path) + .and_return(MultiJson.dump('currentContext' => context_name)) + allow(File).to receive(:exist?).with(meta_path).and_return(false) + end + + its(:url) { should == 'unix:///var/run/docker.sock' } + end + + context 'when DOCKER_HOST is set, env takes precedence over context' do + before do + allow(ENV).to receive(:[]).with('DOCKER_HOST').and_return('tcp://from-env:2375') + allow(File).to receive(:exist?).with(config_path).and_return(true) + allow(File).to receive(:read).with(config_path) + .and_return(MultiJson.dump('currentContext' => context_name)) + allow(File).to receive(:exist?).with(meta_path).and_return(true) + allow(File).to receive(:read).with(meta_path) + .and_return(MultiJson.dump('Endpoints' => { 'docker' => { 'Host' => context_host } })) + end + + its(:url) { should == 'tcp://from-env:2375' } + end + + context 'when DOCKER_API_SKIP_CONTEXT=1, context lookup is disabled' do + before do + allow(ENV).to receive(:[]).with('DOCKER_API_SKIP_CONTEXT').and_return('1') + allow(File).to receive(:exist?).with(config_path).and_return(true) + allow(File).to receive(:read).with(config_path) + .and_return(MultiJson.dump('currentContext' => context_name)) + end + + its(:url) { should == 'unix:///var/run/docker.sock' } + end + end + describe '#reset_connection!' do before { subject.connection } it 'sets the @connection to nil' do