Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<sha256(name)>/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.
Expand Down
2 changes: 2 additions & 0 deletions docker-api.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
47 changes: 44 additions & 3 deletions lib/docker.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
require 'excon'
require 'tempfile'
require 'base64'
require 'digest'
require 'find'
require 'rubygems/package'
require 'uri'
Expand Down Expand Up @@ -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']
{
Expand All @@ -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'
Expand Down Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions lib/docker/connection.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
27 changes: 27 additions & 0 deletions lib/docker/excon_npipe.rb
Original file line number Diff line number Diff line change
@@ -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)
171 changes: 171 additions & 0 deletions lib/docker/npipe_socket.rb
Original file line number Diff line number Diff line change
@@ -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\<name>'.
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
11 changes: 10 additions & 1 deletion spec/docker/connection_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -22,14 +22,23 @@

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:///')
expect(subject.options).to include(:socket => url.split('//').last)
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 }

Expand Down
38 changes: 38 additions & 0 deletions spec/docker/npipe_socket_spec.rb
Original file line number Diff line number Diff line change
@@ -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
Loading