docker-api-ng 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE +202 -0
- data/NOTICE +12 -0
- data/README.md +278 -0
- data/docker-api-ng.gemspec +42 -0
- data/docs/building-images.md +98 -0
- data/docs/connecting.md +111 -0
- data/docs/errors.md +89 -0
- data/docs/exec.md +100 -0
- data/docs/extending.md +109 -0
- data/docs/migrating-from-docker-api.md +156 -0
- data/docs/streaming.md +91 -0
- data/lib/docker/api/auth.rb +154 -0
- data/lib/docker/api/body.rb +47 -0
- data/lib/docker/api/client.rb +117 -0
- data/lib/docker/api/collection.rb +60 -0
- data/lib/docker/api/collections/containers.rb +78 -0
- data/lib/docker/api/collections/images.rb +221 -0
- data/lib/docker/api/collections/networks.rb +85 -0
- data/lib/docker/api/collections/system.rb +98 -0
- data/lib/docker/api/collections/volumes.rb +54 -0
- data/lib/docker/api/config.rb +163 -0
- data/lib/docker/api/connection.rb +333 -0
- data/lib/docker/api/context.rb +124 -0
- data/lib/docker/api/errors.rb +159 -0
- data/lib/docker/api/operations.rb +3064 -0
- data/lib/docker/api/path.rb +33 -0
- data/lib/docker/api/platform.rb +68 -0
- data/lib/docker/api/query.rb +76 -0
- data/lib/docker/api/resource.rb +174 -0
- data/lib/docker/api/resources/container.rb +378 -0
- data/lib/docker/api/resources/exec.rb +48 -0
- data/lib/docker/api/resources/image.rb +209 -0
- data/lib/docker/api/resources/network.rb +90 -0
- data/lib/docker/api/resources/volume.rb +51 -0
- data/lib/docker/api/response.rb +110 -0
- data/lib/docker/api/session.rb +70 -0
- data/lib/docker/api/stream.rb +142 -0
- data/lib/docker/api/tar.rb +157 -0
- data/lib/docker/api/transport/base.rb +62 -0
- data/lib/docker/api/transport/fake.rb +154 -0
- data/lib/docker/api/transport/named_pipe.rb +58 -0
- data/lib/docker/api/transport/tcp.rb +49 -0
- data/lib/docker/api/transport/tls.rb +84 -0
- data/lib/docker/api/transport/unix.rb +35 -0
- data/lib/docker/api/transport.rb +113 -0
- data/lib/docker/api/version.rb +21 -0
- data/lib/docker/api.rb +68 -0
- data/sig/docker/api/core.rbs +109 -0
- data/sig/docker/api/operations.rbs +123 -0
- metadata +97 -0
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
#
|
|
3
|
+
# Copyright 2026 Tim Smith
|
|
4
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
5
|
+
|
|
6
|
+
module Docker
|
|
7
|
+
module API
|
|
8
|
+
module Transport
|
|
9
|
+
# A scripted daemon for tests, served over a real socket pair.
|
|
10
|
+
#
|
|
11
|
+
# The socket is genuine on purpose. `Net::BufferedIO` calls
|
|
12
|
+
# `read_nonblock`, `write` and `to_io` on whatever it is handed, and a
|
|
13
|
+
# StringIO does not honour that contract. Faking it produces tests that
|
|
14
|
+
# pass against a double the production code could never actually drive.
|
|
15
|
+
# `Socket.pair` gives real socket semantics with no network, so the
|
|
16
|
+
# connection layer is exercised exactly as it runs against a daemon --
|
|
17
|
+
# including chunked bodies, keep-alive and connection upgrades.
|
|
18
|
+
#
|
|
19
|
+
# @example Scripting one response
|
|
20
|
+
# fake = Docker::API::Transport::Fake.new([
|
|
21
|
+
# "HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok",
|
|
22
|
+
# ])
|
|
23
|
+
# client = Docker::API::Client.new(transport: fake, api_version: "1.55")
|
|
24
|
+
# client.system.info
|
|
25
|
+
# fake.requests.first #=> "GET /v1.55/info HTTP/1.1\r\n..."
|
|
26
|
+
class Fake < Base
|
|
27
|
+
# @return [Array<String>] raw request bytes, in the order received
|
|
28
|
+
attr_reader :requests
|
|
29
|
+
|
|
30
|
+
# @param responses [Array<String>] raw HTTP responses to serve, in order
|
|
31
|
+
def initialize(responses = [])
|
|
32
|
+
super()
|
|
33
|
+
@responses = Array(responses).dup
|
|
34
|
+
@requests = []
|
|
35
|
+
@mutex = Mutex.new
|
|
36
|
+
@threads = []
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Add another scripted response.
|
|
40
|
+
#
|
|
41
|
+
# @param response [String] raw HTTP response bytes
|
|
42
|
+
# @return [self]
|
|
43
|
+
def <<(response)
|
|
44
|
+
@mutex.synchronize { @responses << response }
|
|
45
|
+
self
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# @return [IO] the client end of a socket pair with a server behind it
|
|
49
|
+
def connect
|
|
50
|
+
ours, theirs = Socket.pair(:UNIX, :STREAM)
|
|
51
|
+
thread = Thread.new { serve(theirs) }
|
|
52
|
+
thread.report_on_exception = false
|
|
53
|
+
@threads << thread
|
|
54
|
+
ours
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Wait for the scripted server to finish, so assertions about recorded
|
|
58
|
+
# requests do not race the thread that records them.
|
|
59
|
+
#
|
|
60
|
+
# @return [void]
|
|
61
|
+
def finish
|
|
62
|
+
@threads.each { |thread| thread.join(2) }
|
|
63
|
+
self
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# @return [String]
|
|
67
|
+
def to_s
|
|
68
|
+
"#<Docker::API::Transport::Fake scripted=#{@responses.size}>"
|
|
69
|
+
end
|
|
70
|
+
alias_method :inspect, :to_s
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
# Serve requests until the client goes away or the script runs out.
|
|
75
|
+
#
|
|
76
|
+
# Hanging up once the last scripted response has been written is what
|
|
77
|
+
# lets a client that reads to end-of-stream finish. A server that
|
|
78
|
+
# politely waited for another request would leave every such read
|
|
79
|
+
# blocked forever, which is a hang rather than a failure and therefore
|
|
80
|
+
# far more annoying to diagnose.
|
|
81
|
+
#
|
|
82
|
+
# @param io [IO] the server end of the pair
|
|
83
|
+
# @return [void]
|
|
84
|
+
def serve(io)
|
|
85
|
+
loop do
|
|
86
|
+
request = read_request(io)
|
|
87
|
+
break if request.nil?
|
|
88
|
+
|
|
89
|
+
@mutex.synchronize { @requests << request }
|
|
90
|
+
response, remaining = @mutex.synchronize { [@responses.shift, @responses.size] }
|
|
91
|
+
break if response.nil?
|
|
92
|
+
|
|
93
|
+
io.write(response)
|
|
94
|
+
break if remaining == 0
|
|
95
|
+
end
|
|
96
|
+
rescue IOError, SystemCallError
|
|
97
|
+
nil
|
|
98
|
+
ensure
|
|
99
|
+
begin
|
|
100
|
+
io.close unless io.closed?
|
|
101
|
+
rescue IOError, SystemCallError
|
|
102
|
+
nil
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Read one complete HTTP request: the head, then the body, however
|
|
107
|
+
# the client chose to frame it.
|
|
108
|
+
#
|
|
109
|
+
# Chunked bodies matter here rather than being pedantry. A build
|
|
110
|
+
# context is streamed from an IO, so Net::HTTP sends it chunked with no
|
|
111
|
+
# Content-Length. A fake that stops after the head leaves the body in
|
|
112
|
+
# the socket, reads it as if it were the next request, and quietly
|
|
113
|
+
# consumes the response scripted for the call after this one.
|
|
114
|
+
#
|
|
115
|
+
# @param io [IO] the server end of the pair
|
|
116
|
+
# @return [String, nil] raw request bytes, or nil at end of stream
|
|
117
|
+
def read_request(io)
|
|
118
|
+
head = +""
|
|
119
|
+
until head.end_with?("\r\n\r\n")
|
|
120
|
+
line = io.gets
|
|
121
|
+
return nil if line.nil?
|
|
122
|
+
|
|
123
|
+
head << line
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
return head + read_chunked_body(io) if head.match?(/^Transfer-Encoding:\s*chunked/i)
|
|
127
|
+
|
|
128
|
+
length = head[/^Content-Length:\s*(\d+)/i, 1]
|
|
129
|
+
return head if length.nil?
|
|
130
|
+
|
|
131
|
+
head + io.read(Integer(length)).to_s
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# @param io [IO] the server end of the pair
|
|
135
|
+
# @return [String] the decoded body
|
|
136
|
+
def read_chunked_body(io)
|
|
137
|
+
body = +""
|
|
138
|
+
loop do
|
|
139
|
+
size_line = io.gets
|
|
140
|
+
break if size_line.nil?
|
|
141
|
+
|
|
142
|
+
size = size_line.strip.split(";").first.to_i(16)
|
|
143
|
+
break if size == 0
|
|
144
|
+
|
|
145
|
+
body << io.read(size).to_s
|
|
146
|
+
io.read(2) # the CRLF that terminates the chunk
|
|
147
|
+
end
|
|
148
|
+
io.gets # the blank line after the terminating chunk
|
|
149
|
+
body
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
#
|
|
3
|
+
# Copyright 2026 Tim Smith
|
|
4
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
5
|
+
|
|
6
|
+
module Docker
|
|
7
|
+
module API
|
|
8
|
+
module Transport
|
|
9
|
+
# A daemon reached over a Windows named pipe, which is how Docker Desktop
|
|
10
|
+
# for Windows exposes the Engine API by default.
|
|
11
|
+
#
|
|
12
|
+
# Windows named pipes present as files, so a binary-mode File is a
|
|
13
|
+
# bidirectional stream that behaves closely enough to a socket for the
|
|
14
|
+
# layer above. This is the transport the docker-api gem never grew, which
|
|
15
|
+
# is why kitchen-dokken carries a standing TODO about Windows support.
|
|
16
|
+
class NamedPipe < Base
|
|
17
|
+
# The pipe Docker Desktop for Windows publishes by default.
|
|
18
|
+
DEFAULT_PIPE = "//./pipe/docker_engine"
|
|
19
|
+
|
|
20
|
+
# @return [String] the pipe path
|
|
21
|
+
attr_reader :path
|
|
22
|
+
|
|
23
|
+
# @param path [String] the pipe path
|
|
24
|
+
def initialize(path: DEFAULT_PIPE)
|
|
25
|
+
super()
|
|
26
|
+
@path = path
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# @return [IO] the opened pipe
|
|
30
|
+
# @raise [Docker::API::ConnectionError]
|
|
31
|
+
def connect
|
|
32
|
+
dial("npipe://#{path}") do
|
|
33
|
+
pipe = File.open(windows_path, "r+b")
|
|
34
|
+
pipe.sync = true
|
|
35
|
+
pipe
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# @return [String]
|
|
40
|
+
def to_s
|
|
41
|
+
"#<Docker::API::Transport::NamedPipe path=#{path}>"
|
|
42
|
+
end
|
|
43
|
+
alias_method :inspect, :to_s
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
# Accept both the forward-slash form that appears in DOCKER_HOST and
|
|
48
|
+
# the backslash form Windows itself uses, rather than making callers
|
|
49
|
+
# care which one they happen to have.
|
|
50
|
+
#
|
|
51
|
+
# @return [String]
|
|
52
|
+
def windows_path
|
|
53
|
+
path.tr("/", "\\")
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
#
|
|
3
|
+
# Copyright 2026 Tim Smith
|
|
4
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
5
|
+
|
|
6
|
+
module Docker
|
|
7
|
+
module API
|
|
8
|
+
module Transport
|
|
9
|
+
# A daemon reached over plain TCP. Unencrypted, so appropriate only on a
|
|
10
|
+
# trusted network or a loopback address.
|
|
11
|
+
class Tcp < Base
|
|
12
|
+
# @return [String] the host
|
|
13
|
+
attr_reader :host
|
|
14
|
+
|
|
15
|
+
# @return [Integer] the port
|
|
16
|
+
attr_reader :port
|
|
17
|
+
|
|
18
|
+
# @param host [String] the host to dial
|
|
19
|
+
# @param port [Integer] the port to dial
|
|
20
|
+
# @param open_timeout [Numeric] seconds to wait for the connection
|
|
21
|
+
def initialize(host:, port:, open_timeout: 10)
|
|
22
|
+
super()
|
|
23
|
+
@host = host
|
|
24
|
+
@port = Integer(port)
|
|
25
|
+
@open_timeout = open_timeout
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# @return [IO] a connected TCP socket
|
|
29
|
+
# @raise [Docker::API::ConnectionError]
|
|
30
|
+
def connect
|
|
31
|
+
dial("tcp://#{host}:#{port}") do
|
|
32
|
+
Socket.tcp(host, port, connect_timeout: @open_timeout)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# @return [String]
|
|
37
|
+
def host_header
|
|
38
|
+
"#{host}:#{port}"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# @return [String]
|
|
42
|
+
def to_s
|
|
43
|
+
"#<Docker::API::Transport::Tcp host=#{host} port=#{port}>"
|
|
44
|
+
end
|
|
45
|
+
alias_method :inspect, :to_s
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
#
|
|
3
|
+
# Copyright 2026 Tim Smith
|
|
4
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
5
|
+
|
|
6
|
+
module Docker
|
|
7
|
+
module API
|
|
8
|
+
module Transport
|
|
9
|
+
# A daemon reached over TLS, as `DOCKER_CERT_PATH` configures.
|
|
10
|
+
#
|
|
11
|
+
# The handshake happens here rather than in Net::HTTP. That is deliberate:
|
|
12
|
+
# the layer above receives an already-encrypted socket and does not need
|
|
13
|
+
# to know whether TLS is in play, which keeps one code path for every
|
|
14
|
+
# transport.
|
|
15
|
+
class Tls < Tcp
|
|
16
|
+
# @param host [String] the host to dial
|
|
17
|
+
# @param port [Integer] the port to dial
|
|
18
|
+
# @param ca_file [String, nil] path to the CA bundle
|
|
19
|
+
# @param cert_file [String, nil] path to the client certificate
|
|
20
|
+
# @param key_file [String, nil] path to the client key
|
|
21
|
+
# @param verify [Boolean] whether to verify the daemon's certificate
|
|
22
|
+
# @param open_timeout [Numeric] seconds to wait for the connection
|
|
23
|
+
def initialize(host:, port:, ca_file: nil, cert_file: nil, key_file: nil,
|
|
24
|
+
verify: true, open_timeout: 10)
|
|
25
|
+
super(host: host, port: port, open_timeout: open_timeout)
|
|
26
|
+
@ca_file = ca_file
|
|
27
|
+
@cert_file = cert_file
|
|
28
|
+
@key_file = key_file
|
|
29
|
+
@verify = verify
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# @return [OpenSSL::SSL::SSLSocket] a connected, handshaken socket
|
|
33
|
+
# @raise [Docker::API::ConnectionError]
|
|
34
|
+
def connect
|
|
35
|
+
dial("https://#{host}:#{port}") do
|
|
36
|
+
# The raw socket is closed by hand if anything between here and a
|
|
37
|
+
# completed handshake raises. sync_close only ties the two together
|
|
38
|
+
# once an SSLSocket exists and owns it, so an expired certificate,
|
|
39
|
+
# a hostname mismatch or an untrusted CA used to leave the
|
|
40
|
+
# descriptor open until GC got to it -- and a retry loop waiting for
|
|
41
|
+
# a daemon to come up exhausts descriptors rather than failing.
|
|
42
|
+
raw = super_socket
|
|
43
|
+
begin
|
|
44
|
+
socket = OpenSSL::SSL::SSLSocket.new(raw, ssl_context)
|
|
45
|
+
socket.hostname = host # SNI, which some proxies in front of a daemon require
|
|
46
|
+
socket.sync_close = true
|
|
47
|
+
socket.connect
|
|
48
|
+
socket
|
|
49
|
+
rescue StandardError
|
|
50
|
+
raw.close unless raw.closed?
|
|
51
|
+
raise
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# @return [String]
|
|
57
|
+
def to_s
|
|
58
|
+
"#<Docker::API::Transport::Tls host=#{host} port=#{port} verify=#{@verify}>"
|
|
59
|
+
end
|
|
60
|
+
alias_method :inspect, :to_s
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
# @return [IO] the underlying TCP socket
|
|
65
|
+
def super_socket
|
|
66
|
+
Socket.tcp(host, port, connect_timeout: @open_timeout)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# @return [OpenSSL::SSL::SSLContext]
|
|
70
|
+
def ssl_context
|
|
71
|
+
context = OpenSSL::SSL::SSLContext.new
|
|
72
|
+
context.min_version = OpenSSL::SSL::TLS1_2_VERSION
|
|
73
|
+
context.verify_mode = @verify ? OpenSSL::SSL::VERIFY_PEER : OpenSSL::SSL::VERIFY_NONE
|
|
74
|
+
context.ca_file = @ca_file if @ca_file
|
|
75
|
+
if @cert_file && @key_file
|
|
76
|
+
context.cert = OpenSSL::X509::Certificate.new(File.read(@cert_file))
|
|
77
|
+
context.key = OpenSSL::PKey.read(File.read(@key_file))
|
|
78
|
+
end
|
|
79
|
+
context
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
#
|
|
3
|
+
# Copyright 2026 Tim Smith
|
|
4
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
5
|
+
|
|
6
|
+
module Docker
|
|
7
|
+
module API
|
|
8
|
+
module Transport
|
|
9
|
+
# A daemon reached over a unix domain socket, which is how Docker is
|
|
10
|
+
# reached on Linux and macOS by default.
|
|
11
|
+
class Unix < Base
|
|
12
|
+
# @return [String] the socket path
|
|
13
|
+
attr_reader :path
|
|
14
|
+
|
|
15
|
+
# @param path [String] the socket path, e.g. "/var/run/docker.sock"
|
|
16
|
+
def initialize(path:)
|
|
17
|
+
super()
|
|
18
|
+
@path = path
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# @return [IO] a connected unix socket
|
|
22
|
+
# @raise [Docker::API::ConnectionError]
|
|
23
|
+
def connect
|
|
24
|
+
dial("unix://#{path}") { UNIXSocket.new(path) }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# @return [String]
|
|
28
|
+
def to_s
|
|
29
|
+
"#<Docker::API::Transport::Unix path=#{path}>"
|
|
30
|
+
end
|
|
31
|
+
alias_method :inspect, :to_s
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
#
|
|
3
|
+
# Copyright 2026 Tim Smith
|
|
4
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
5
|
+
|
|
6
|
+
module Docker
|
|
7
|
+
module API
|
|
8
|
+
# Transports know how to reach a daemon, and nothing else.
|
|
9
|
+
#
|
|
10
|
+
# Every transport answers {Base#connect} with a connected IO. That single
|
|
11
|
+
# responsibility is what lets the same connection code drive a unix socket
|
|
12
|
+
# in development, TLS in CI and a Windows named pipe on a laptop.
|
|
13
|
+
module Transport
|
|
14
|
+
# Build the transport a Docker host URL calls for.
|
|
15
|
+
#
|
|
16
|
+
# @param url [String] a Docker host URL. Understands `unix://`, `tcp://`,
|
|
17
|
+
# `http://`, `https://`, `npipe://`, and a bare path (taken as a unix
|
|
18
|
+
# socket), matching what `DOCKER_HOST` may contain.
|
|
19
|
+
# @param tls [Hash, nil] TLS material: `:ca_file`, `:cert_file`,
|
|
20
|
+
# `:key_file`, `:verify`. Any of these upgrades a `tcp://` URL to TLS.
|
|
21
|
+
# @param open_timeout [Numeric] seconds to wait for a connection
|
|
22
|
+
# @return [Docker::API::Transport::Base]
|
|
23
|
+
# @raise [Docker::API::ConnectionError] if the scheme is not one we speak
|
|
24
|
+
#
|
|
25
|
+
# @example
|
|
26
|
+
# Transport.for(url: "unix:///var/run/docker.sock")
|
|
27
|
+
# Transport.for(url: "tcp://build:2376", tls: { ca_file: "ca.pem" })
|
|
28
|
+
def self.for(url:, tls: nil, open_timeout: 10)
|
|
29
|
+
scheme, rest = split(url.to_s)
|
|
30
|
+
tls = nil if tls.respond_to?(:empty?) && tls.empty?
|
|
31
|
+
|
|
32
|
+
case scheme
|
|
33
|
+
when "unix"
|
|
34
|
+
Unix.new(path: rest)
|
|
35
|
+
when "npipe"
|
|
36
|
+
NamedPipe.new(path: rest)
|
|
37
|
+
when "https"
|
|
38
|
+
tcp_transport(rest, tls || {}, open_timeout, secure: true)
|
|
39
|
+
when "tcp", "http"
|
|
40
|
+
tcp_transport(rest, tls, open_timeout, secure: !tls.nil?)
|
|
41
|
+
else
|
|
42
|
+
raise ConnectionError.new(
|
|
43
|
+
"cannot reach a Docker daemon over '#{scheme}' (from #{url.inspect}); " \
|
|
44
|
+
"expected one of unix, tcp, http, https or npipe"
|
|
45
|
+
)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Split a host URL into a scheme and the remainder.
|
|
50
|
+
#
|
|
51
|
+
# Hand-parsed rather than handed to URI, because `unix:///var/run/x.sock`
|
|
52
|
+
# and `npipe:////./pipe/docker_engine` are not URLs that URI's generic
|
|
53
|
+
# parser handles the way Docker means them.
|
|
54
|
+
#
|
|
55
|
+
# @param url [String]
|
|
56
|
+
# @return [Array(String, String)] the scheme and the remainder
|
|
57
|
+
# @api private
|
|
58
|
+
def self.split(url)
|
|
59
|
+
if (match = url.match(%r{\A(?<scheme>[a-z][a-z0-9+.-]*)://(?<rest>.*)\z}i))
|
|
60
|
+
scheme = match[:scheme].downcase
|
|
61
|
+
rest = match[:rest]
|
|
62
|
+
# unix and npipe carry a path, and the leading slash is part of it.
|
|
63
|
+
rest = "/#{rest}" if %w{unix npipe}.include?(scheme) && !rest.start_with?("/")
|
|
64
|
+
[scheme, rest]
|
|
65
|
+
elsif url.start_with?("/", "\\") || url.empty?
|
|
66
|
+
# A bare path is what a daemon on a unix socket looks like.
|
|
67
|
+
["unix", url.empty? ? "/var/run/docker.sock" : url]
|
|
68
|
+
else
|
|
69
|
+
["tcp", url]
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
private_class_method :split
|
|
73
|
+
|
|
74
|
+
# @param authority [String] "host:port" or "host"
|
|
75
|
+
# @param tls [Hash, nil] TLS material
|
|
76
|
+
# @param open_timeout [Numeric]
|
|
77
|
+
# @param secure [Boolean]
|
|
78
|
+
# @return [Docker::API::Transport::Tcp, Docker::API::Transport::Tls]
|
|
79
|
+
# @api private
|
|
80
|
+
def self.tcp_transport(authority, tls, open_timeout, secure:)
|
|
81
|
+
host, port = split_authority(authority, secure: secure)
|
|
82
|
+
|
|
83
|
+
return Tcp.new(host: host, port: port, open_timeout: open_timeout) unless secure
|
|
84
|
+
|
|
85
|
+
tls ||= {}
|
|
86
|
+
Tls.new(
|
|
87
|
+
host: host, port: port, open_timeout: open_timeout,
|
|
88
|
+
ca_file: tls[:ca_file], cert_file: tls[:cert_file], key_file: tls[:key_file],
|
|
89
|
+
verify: tls.fetch(:verify, true)
|
|
90
|
+
)
|
|
91
|
+
end
|
|
92
|
+
private_class_method :tcp_transport
|
|
93
|
+
|
|
94
|
+
# @param authority [String]
|
|
95
|
+
# @param secure [Boolean]
|
|
96
|
+
# @return [Array(String, Integer)]
|
|
97
|
+
# @api private
|
|
98
|
+
def self.split_authority(authority, secure:)
|
|
99
|
+
authority = authority.sub(%r{/.*\z}, "")
|
|
100
|
+
default_port = secure ? 2376 : 2375
|
|
101
|
+
# `tcp://` with nothing after it means localhost, which is what the
|
|
102
|
+
# Docker CLI does with a bare scheme.
|
|
103
|
+
return ["localhost", default_port] if authority.empty?
|
|
104
|
+
|
|
105
|
+
host, _, port = authority.rpartition(":")
|
|
106
|
+
return [authority, default_port] if host.empty?
|
|
107
|
+
|
|
108
|
+
[host, port.empty? ? default_port : Integer(port)]
|
|
109
|
+
end
|
|
110
|
+
private_class_method :split_authority
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
#
|
|
3
|
+
# Copyright 2026 Tim Smith
|
|
4
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
5
|
+
|
|
6
|
+
module Docker
|
|
7
|
+
module API
|
|
8
|
+
# The gem's own version.
|
|
9
|
+
VERSION = "0.3.0"
|
|
10
|
+
|
|
11
|
+
# The newest Engine API version this gem vendors a specification for.
|
|
12
|
+
# Requests are never made against a version above this, even when the
|
|
13
|
+
# daemon offers one, because the generated layer only knows this shape.
|
|
14
|
+
MAX_API_VERSION = "1.55"
|
|
15
|
+
|
|
16
|
+
# The oldest Engine API version this gem will talk to. Docker 20.10 is the
|
|
17
|
+
# oldest release still plausibly in service; below it, enough of the API
|
|
18
|
+
# differs that failing loudly beats failing mysteriously.
|
|
19
|
+
MIN_API_VERSION = "1.41"
|
|
20
|
+
end
|
|
21
|
+
end
|
data/lib/docker/api.rb
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
#
|
|
3
|
+
# Copyright 2026 Tim Smith
|
|
4
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
5
|
+
|
|
6
|
+
require "json" unless defined?(JSON)
|
|
7
|
+
require "net/http" unless defined?(Net::HTTP)
|
|
8
|
+
require "openssl" unless defined?(OpenSSL)
|
|
9
|
+
require "socket" unless defined?(Socket)
|
|
10
|
+
require "stringio" unless defined?(StringIO)
|
|
11
|
+
require "uri" unless defined?(URI)
|
|
12
|
+
|
|
13
|
+
# The `docker-api` gem also owns the `Docker` namespace. This gem reopens it
|
|
14
|
+
# additively and defines nothing directly on it, so both can be loaded into one
|
|
15
|
+
# process -- which is what makes a gradual migration possible rather than a
|
|
16
|
+
# flag day.
|
|
17
|
+
module Docker
|
|
18
|
+
# A Ruby client for the modern Docker Engine API.
|
|
19
|
+
#
|
|
20
|
+
# The complete API surface is generated from Docker's own OpenAPI
|
|
21
|
+
# specification and reachable through {Client#operations}; an ergonomic
|
|
22
|
+
# hand-written layer of collections and resources sits on top of it.
|
|
23
|
+
#
|
|
24
|
+
# @example Talking to the local daemon
|
|
25
|
+
# client = Docker::API::Client.new
|
|
26
|
+
# client.system.info["ServerVersion"]
|
|
27
|
+
#
|
|
28
|
+
# @example Two daemons at once, with no shared state
|
|
29
|
+
# local = Docker::API::Client.new
|
|
30
|
+
# build = Docker::API::Client.new(url: "tcp://build.internal:2376")
|
|
31
|
+
module API
|
|
32
|
+
require_relative "api/version"
|
|
33
|
+
require_relative "api/errors"
|
|
34
|
+
require_relative "api/response"
|
|
35
|
+
require_relative "api/query"
|
|
36
|
+
require_relative "api/path"
|
|
37
|
+
require_relative "api/body"
|
|
38
|
+
require_relative "api/platform"
|
|
39
|
+
require_relative "api/tar"
|
|
40
|
+
require_relative "api/transport/base"
|
|
41
|
+
require_relative "api/transport/unix"
|
|
42
|
+
require_relative "api/transport/tcp"
|
|
43
|
+
require_relative "api/transport/tls"
|
|
44
|
+
require_relative "api/transport/named_pipe"
|
|
45
|
+
require_relative "api/transport/fake"
|
|
46
|
+
require_relative "api/transport"
|
|
47
|
+
require_relative "api/session"
|
|
48
|
+
require_relative "api/stream"
|
|
49
|
+
require_relative "api/connection"
|
|
50
|
+
require_relative "api/context"
|
|
51
|
+
require_relative "api/config"
|
|
52
|
+
require_relative "api/auth"
|
|
53
|
+
require_relative "api/operations"
|
|
54
|
+
require_relative "api/collection"
|
|
55
|
+
require_relative "api/resource"
|
|
56
|
+
require_relative "api/resources/exec"
|
|
57
|
+
require_relative "api/resources/container"
|
|
58
|
+
require_relative "api/resources/image"
|
|
59
|
+
require_relative "api/resources/network"
|
|
60
|
+
require_relative "api/resources/volume"
|
|
61
|
+
require_relative "api/collections/containers"
|
|
62
|
+
require_relative "api/collections/images"
|
|
63
|
+
require_relative "api/collections/networks"
|
|
64
|
+
require_relative "api/collections/volumes"
|
|
65
|
+
require_relative "api/collections/system"
|
|
66
|
+
require_relative "api/client"
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# Hand-written signatures for the layers the generated operations depend on.
|
|
2
|
+
#
|
|
3
|
+
# Kept deliberately narrow: enough to type-check the generated file against the
|
|
4
|
+
# connection it calls, without pinning down internals that are free to change.
|
|
5
|
+
|
|
6
|
+
module Docker
|
|
7
|
+
module API
|
|
8
|
+
VERSION: String
|
|
9
|
+
MAX_API_VERSION: String
|
|
10
|
+
MIN_API_VERSION: String
|
|
11
|
+
|
|
12
|
+
class Error < StandardError
|
|
13
|
+
attr_reader operation: String?
|
|
14
|
+
attr_reader status: Integer?
|
|
15
|
+
attr_reader response: Docker::API::Response?
|
|
16
|
+
|
|
17
|
+
def initialize: (?String? message, ?operation: (String | Symbol)?, ?status: Integer?, ?response: Docker::API::Response?) -> void
|
|
18
|
+
def self.for: (status: Integer, ?operation: (String | Symbol)?, ?response: Docker::API::Response?) -> Docker::API::Error
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
class ConnectionError < Error
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
class TimeoutError < Error
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
class ClientError < Error
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
class BadRequest < ClientError
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
class Unauthorized < ClientError
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
class Forbidden < ClientError
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
class NotFound < ClientError
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
class NotModified < ClientError
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
class Conflict < ClientError
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
class ServerError < Error
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
class VersionUnsupported < Error
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
class StreamError < Error
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
class Response
|
|
58
|
+
attr_reader status: Integer
|
|
59
|
+
attr_reader headers: Hash[String, String]
|
|
60
|
+
attr_reader body: String
|
|
61
|
+
|
|
62
|
+
def initialize: (status: Integer, ?headers: Hash[untyped, untyped], ?body: String?) -> void
|
|
63
|
+
def json: () -> untyped
|
|
64
|
+
def success?: () -> bool
|
|
65
|
+
def []: ((String | Symbol) name) -> String?
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
class Connection
|
|
69
|
+
UNVERSIONED: Array[String]
|
|
70
|
+
|
|
71
|
+
def initialize: (transport: untyped, ?api_version: (String | Symbol), ?logger: untyped?, ?read_timeout: Numeric, ?open_timeout: Numeric) -> void
|
|
72
|
+
def api_version: () -> String?
|
|
73
|
+
def ping: () -> Docker::API::Response
|
|
74
|
+
def request: (
|
|
75
|
+
Symbol http_method,
|
|
76
|
+
String path,
|
|
77
|
+
?query: Hash[untyped, untyped],
|
|
78
|
+
?body: untyped,
|
|
79
|
+
?headers: Hash[untyped, untyped],
|
|
80
|
+
?expects: Array[Integer],
|
|
81
|
+
?operation: (String | Symbol)?,
|
|
82
|
+
?content_type: String?
|
|
83
|
+
) ?{ (String) -> void } -> Docker::API::Response
|
|
84
|
+
def hijack: (
|
|
85
|
+
Symbol http_method,
|
|
86
|
+
String path,
|
|
87
|
+
?query: Hash[untyped, untyped],
|
|
88
|
+
?body: untyped,
|
|
89
|
+
?headers: Hash[untyped, untyped],
|
|
90
|
+
?operation: (String | Symbol)?
|
|
91
|
+
) -> IO
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
module Path
|
|
95
|
+
SAFE: Regexp
|
|
96
|
+
|
|
97
|
+
def self.escape: (untyped value) -> String
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
module Query
|
|
101
|
+
def self.encode: (Hash[untyped, untyped]? params) -> String
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
module Platform
|
|
105
|
+
def self.oci: (untyped value) -> Hash[String, String]?
|
|
106
|
+
def self.string: (untyped value) -> String?
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|