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.
Files changed (51) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +202 -0
  3. data/NOTICE +12 -0
  4. data/README.md +278 -0
  5. data/docker-api-ng.gemspec +42 -0
  6. data/docs/building-images.md +98 -0
  7. data/docs/connecting.md +111 -0
  8. data/docs/errors.md +89 -0
  9. data/docs/exec.md +100 -0
  10. data/docs/extending.md +109 -0
  11. data/docs/migrating-from-docker-api.md +156 -0
  12. data/docs/streaming.md +91 -0
  13. data/lib/docker/api/auth.rb +154 -0
  14. data/lib/docker/api/body.rb +47 -0
  15. data/lib/docker/api/client.rb +117 -0
  16. data/lib/docker/api/collection.rb +60 -0
  17. data/lib/docker/api/collections/containers.rb +78 -0
  18. data/lib/docker/api/collections/images.rb +221 -0
  19. data/lib/docker/api/collections/networks.rb +85 -0
  20. data/lib/docker/api/collections/system.rb +98 -0
  21. data/lib/docker/api/collections/volumes.rb +54 -0
  22. data/lib/docker/api/config.rb +163 -0
  23. data/lib/docker/api/connection.rb +333 -0
  24. data/lib/docker/api/context.rb +124 -0
  25. data/lib/docker/api/errors.rb +159 -0
  26. data/lib/docker/api/operations.rb +3064 -0
  27. data/lib/docker/api/path.rb +33 -0
  28. data/lib/docker/api/platform.rb +68 -0
  29. data/lib/docker/api/query.rb +76 -0
  30. data/lib/docker/api/resource.rb +174 -0
  31. data/lib/docker/api/resources/container.rb +378 -0
  32. data/lib/docker/api/resources/exec.rb +48 -0
  33. data/lib/docker/api/resources/image.rb +209 -0
  34. data/lib/docker/api/resources/network.rb +90 -0
  35. data/lib/docker/api/resources/volume.rb +51 -0
  36. data/lib/docker/api/response.rb +110 -0
  37. data/lib/docker/api/session.rb +70 -0
  38. data/lib/docker/api/stream.rb +142 -0
  39. data/lib/docker/api/tar.rb +157 -0
  40. data/lib/docker/api/transport/base.rb +62 -0
  41. data/lib/docker/api/transport/fake.rb +154 -0
  42. data/lib/docker/api/transport/named_pipe.rb +58 -0
  43. data/lib/docker/api/transport/tcp.rb +49 -0
  44. data/lib/docker/api/transport/tls.rb +84 -0
  45. data/lib/docker/api/transport/unix.rb +35 -0
  46. data/lib/docker/api/transport.rb +113 -0
  47. data/lib/docker/api/version.rb +21 -0
  48. data/lib/docker/api.rb +68 -0
  49. data/sig/docker/api/core.rbs +109 -0
  50. data/sig/docker/api/operations.rbs +123 -0
  51. metadata +97 -0
@@ -0,0 +1,333 @@
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
+ # Everything between "a socket exists" and "a Ruby value comes back".
9
+ #
10
+ # A connection owns request building, API version negotiation, error
11
+ # mapping, streaming and hijack. It is immutable once built: there is no
12
+ # setter for the URL or for credentials, which is what lets two connections
13
+ # address two daemons in one process without interfering.
14
+ class Connection
15
+ # Endpoints that must not carry a version prefix, because they are how we
16
+ # discover which version to use.
17
+ UNVERSIONED = ["/_ping"].freeze
18
+
19
+ # HTTP verbs mapped to the Net::HTTP request classes that implement them.
20
+ VERBS = {
21
+ get: Net::HTTP::Get,
22
+ post: Net::HTTP::Post,
23
+ put: Net::HTTP::Put,
24
+ patch: Net::HTTP::Patch,
25
+ delete: Net::HTTP::Delete,
26
+ head: Net::HTTP::Head,
27
+ }.freeze
28
+
29
+ # @return [Docker::API::Transport::Base] the transport in use
30
+ attr_reader :transport
31
+
32
+ # @return [Logger, nil] where request metadata is logged, if anywhere
33
+ attr_reader :logger
34
+
35
+ # @param transport [Docker::API::Transport::Base] how to reach the daemon
36
+ # @param api_version [String, Symbol] `:negotiate` to ask the daemon,
37
+ # a version string such as `"1.44"` to pin, or `:none` to send
38
+ # unprefixed paths
39
+ # @param logger [Logger, nil] receives one debug line per request
40
+ # @param read_timeout [Numeric] seconds to wait for response data
41
+ # @param open_timeout [Numeric] seconds to wait for a connection
42
+ def initialize(transport:, api_version: :negotiate, logger: nil,
43
+ read_timeout: 60, open_timeout: 10)
44
+ @transport = transport
45
+ @configured_api_version = api_version
46
+ @logger = logger
47
+ @read_timeout = read_timeout
48
+ @open_timeout = open_timeout
49
+ @version_lock = Mutex.new
50
+ end
51
+
52
+ # The API version this connection prefixes its requests with.
53
+ #
54
+ # Negotiation happens once, lazily, on the first request that needs it.
55
+ #
56
+ # @return [String, nil] the version, or nil when versioning is disabled
57
+ # @raise [Docker::API::VersionUnsupported] if the daemon is too old
58
+ def api_version
59
+ return @api_version if defined?(@api_version)
60
+
61
+ @version_lock.synchronize do
62
+ @api_version = resolve_api_version unless defined?(@api_version)
63
+ end
64
+ @api_version
65
+ end
66
+
67
+ # Ask the daemon whether it is alive. Never version-prefixed.
68
+ #
69
+ # @return [Docker::API::Response]
70
+ def ping
71
+ request(:get, "/_ping", operation: "ping")
72
+ end
73
+
74
+ # Send a request and interpret the answer.
75
+ #
76
+ # @param method [Symbol] one of :get, :post, :put, :patch, :delete, :head
77
+ # @param path [String] the path, without a version prefix
78
+ # @param query [Hash] query parameters in Ruby types
79
+ # @param body [Hash, Array, String, IO, nil] the request body. Hashes and
80
+ # arrays are JSON-encoded; strings and IOs are sent as-is.
81
+ # @param headers [Hash] additional request headers
82
+ # @param expects [Array<Integer>] statuses that mean success
83
+ # @param operation [String, Symbol, nil] names this call in errors and logs
84
+ # @param content_type [String, nil] overrides the computed content type
85
+ # @yieldparam chunk [String] successive body chunks, when streaming
86
+ # @return [Docker::API::Response] with an empty body when streamed
87
+ # @raise [Docker::API::Error] for any status outside `expects`
88
+ def request(method, path, query: {}, body: nil, headers: {}, expects: [200],
89
+ operation: nil, content_type: nil, &block)
90
+ full_path = build_path(path, query)
91
+ payload, computed_type = encode_body(body)
92
+ request_headers = build_headers(headers, content_type || computed_type, payload)
93
+
94
+ log(method, full_path, operation)
95
+
96
+ response = perform(method, full_path, payload, request_headers, expects, &block)
97
+ return response if expects.include?(response.status)
98
+
99
+ raise Error.for(status: response.status, operation: operation, response: response)
100
+ end
101
+
102
+ # Take over the socket after an upgrade, for interactive exec and attach.
103
+ #
104
+ # This deliberately does not go through Net::HTTP. An interactive session
105
+ # needs the socket itself, and `Net::BufferedIO` may already have read
106
+ # ahead past the response headers. Rather than prise a buffered socket
107
+ # back out of the stdlib -- the problem that produced docker-api's custom
108
+ # Excon middleware -- this writes the request itself and hands back the
109
+ # socket with nothing consumed but the response head.
110
+ #
111
+ # @param method [Symbol] the HTTP verb
112
+ # @param path [String] the path, without a version prefix
113
+ # @param query [Hash] query parameters
114
+ # @param body [Hash, String, nil] the request body
115
+ # @param headers [Hash] additional headers
116
+ # @param operation [String, Symbol, nil] names this call in errors
117
+ # @return [IO] the bidirectional socket, positioned at the stream body
118
+ # @raise [Docker::API::Error] if the daemon refused to upgrade
119
+ def hijack(method, path, query: {}, body: nil, headers: {}, operation: nil)
120
+ io = transport.connect
121
+ full_path = build_path(path, query)
122
+ payload, computed_type = encode_body(body)
123
+
124
+ write_raw_request(io, method, full_path, payload, headers, computed_type)
125
+ status, response_headers = read_raw_response_head(io)
126
+
127
+ unless [101, 200].include?(status)
128
+ error_body = io.read.to_s
129
+ io.close unless io.closed?
130
+ raise Error.for(
131
+ status: status, operation: operation,
132
+ response: Response.new(status: status, headers: response_headers, body: error_body)
133
+ )
134
+ end
135
+
136
+ io
137
+ rescue Errno::EPIPE, Errno::ECONNRESET, EOFError => e
138
+ io&.close unless io.nil? || io.closed?
139
+ raise ConnectionError.new("the daemon closed the connection during #{operation}: #{e.message}")
140
+ end
141
+
142
+ # @return [String]
143
+ def to_s
144
+ "#<Docker::API::Connection transport=#{transport} api_version=#{
145
+ defined?(@api_version) ? @api_version : "unnegotiated"}>"
146
+ end
147
+ alias_method :inspect, :to_s
148
+
149
+ private
150
+
151
+ # @return [String, nil]
152
+ def resolve_api_version
153
+ case @configured_api_version
154
+ when :none then nil
155
+ when :negotiate then negotiate_api_version
156
+ else @configured_api_version.to_s.sub(/\Av/, "")
157
+ end
158
+ end
159
+
160
+ # Ask the daemon what it speaks and meet it in the middle.
161
+ #
162
+ # Pinning to the daemon's version when it is older keeps us inside what
163
+ # it understands; capping at what this gem vendors keeps us inside what
164
+ # the generated layer knows how to ask for.
165
+ #
166
+ # @return [String]
167
+ # @raise [Docker::API::VersionUnsupported]
168
+ def negotiate_api_version
169
+ response = ping
170
+ daemon = response["api-version"]
171
+
172
+ return MAX_API_VERSION if daemon.nil? || daemon.empty?
173
+
174
+ if Gem::Version.new(daemon) < Gem::Version.new(MIN_API_VERSION)
175
+ raise VersionUnsupported.new(
176
+ "the daemon speaks Engine API v#{daemon}, but docker-api-ng requires " \
177
+ "at least v#{MIN_API_VERSION}. Upgrade Docker, or pin an older release of this gem."
178
+ )
179
+ end
180
+
181
+ [Gem::Version.new(daemon), Gem::Version.new(MAX_API_VERSION)].min.to_s
182
+ end
183
+
184
+ # @param path [String]
185
+ # @param query [Hash]
186
+ # @return [String]
187
+ def build_path(path, query)
188
+ prefix = if UNVERSIONED.include?(path) || api_version.nil?
189
+ ""
190
+ else
191
+ "/v#{api_version}"
192
+ end
193
+ "#{prefix}#{path}#{Query.encode(query)}"
194
+ end
195
+
196
+ # Encode a body and work out what to call it.
197
+ #
198
+ # Every raw body in this API is an archive: a build context, a container
199
+ # filesystem, an image tarball. Structured bodies are JSON. There is no
200
+ # third case, so a body-bearing request never goes out unlabelled.
201
+ #
202
+ # That matters more than it looks. The daemon rejects the wrong content
203
+ # type on its archive endpoints -- docker-api carries a middleware that
204
+ # retries after parsing "Content-Type: application/json is not
205
+ # supported. Should be application/x-tar" out of an error body -- and an
206
+ # absent type is at the mercy of whatever the HTTP layer decides to
207
+ # guess.
208
+ #
209
+ # @param body [Object]
210
+ # @return [Array(String, IO, nil, String, nil)] the payload and its content type
211
+ def encode_body(body)
212
+ case body
213
+ when nil then [nil, nil]
214
+ when Hash, Array then [JSON.generate(body), "application/json"]
215
+ else [body, "application/x-tar"]
216
+ end
217
+ end
218
+
219
+ # @param headers [Hash]
220
+ # @param content_type [String, nil]
221
+ # @param payload [Object]
222
+ # @return [Hash{String => String}]
223
+ def build_headers(headers, content_type, payload)
224
+ base = {
225
+ "User-Agent" => "docker-api-ng/#{VERSION} (Ruby #{RUBY_VERSION})",
226
+ "Accept" => "application/json",
227
+ }
228
+ base["Content-Type"] = content_type if content_type && payload
229
+ base.merge(headers.transform_keys(&:to_s))
230
+ end
231
+
232
+ # @return [Docker::API::Response]
233
+ def perform(method, full_path, payload, headers, expects, &block)
234
+ klass = VERBS.fetch(method) { raise ArgumentError, "unsupported HTTP method: #{method.inspect}" }
235
+ request = klass.new(full_path, headers)
236
+ attach_payload(request, payload)
237
+
238
+ session = Session.new(transport, read_timeout: @read_timeout, open_timeout: @open_timeout)
239
+ result = nil
240
+
241
+ session.start do |http|
242
+ http.request(request) do |raw|
243
+ status = raw.code.to_i
244
+ result = if block && expects.include?(status)
245
+ raw.read_body { |chunk| block.call(chunk) }
246
+ Response.new(status: status, headers: raw.to_hash, body: nil)
247
+ else
248
+ Response.new(status: status, headers: raw.to_hash, body: raw.read_body)
249
+ end
250
+ end
251
+ end
252
+
253
+ result
254
+ # Net::OpenTimeout, Net::ReadTimeout and Net::WriteTimeout all descend
255
+ # from Timeout::Error, so naming them individually would only shadow it.
256
+ rescue Timeout::Error => e
257
+ raise TimeoutError.new("#{transport} did not answer in time: #{e.class}: #{e.message}")
258
+ # EOFError is an IOError, so listing it separately would only shadow it.
259
+ rescue Net::HTTPBadResponse, Net::ProtocolError, IOError,
260
+ SystemCallError, SocketError, OpenSSL::SSL::SSLError => e
261
+ raise ConnectionError.new("#{transport} failed mid-request: #{e.class}: #{e.message}")
262
+ end
263
+
264
+ # Streamed by capability rather than by class.
265
+ #
266
+ # `IO, StringIO` looked exhaustive and is not: Tempfile is a delegator
267
+ # around File, so `Tempfile.new.is_a?(IO)` is false. A build context
268
+ # packed to a temporary file would have fallen through to `body = payload`
269
+ # and been sent as the delegator's to_s -- a string like
270
+ # `#<Tempfile:...>` where the daemon expected a tar. What Net::HTTP
271
+ # actually needs from a body stream is `read`, so that is what is asked
272
+ # for.
273
+ #
274
+ # @return [void]
275
+ def attach_payload(request, payload)
276
+ case payload
277
+ when nil then nil
278
+ when String then request.body = payload
279
+ else
280
+ if payload.respond_to?(:read)
281
+ request.body_stream = payload
282
+ request["Transfer-Encoding"] = "chunked"
283
+ else
284
+ request.body = payload
285
+ end
286
+ end
287
+ end
288
+
289
+ # @return [void]
290
+ def write_raw_request(io, method, full_path, payload, headers, content_type)
291
+ lines = ["#{method.to_s.upcase} #{full_path} HTTP/1.1"]
292
+ head = {
293
+ "Host" => transport.host_header,
294
+ "User-Agent" => "docker-api-ng/#{VERSION} (Ruby #{RUBY_VERSION})",
295
+ "Connection" => "Upgrade",
296
+ "Upgrade" => "tcp",
297
+ "Content-Length" => (payload ? payload.bytesize : 0).to_s,
298
+ }
299
+ head["Content-Type"] = content_type if content_type && payload
300
+ head.merge!(headers.transform_keys(&:to_s))
301
+
302
+ head.each { |key, value| lines << "#{key}: #{value}" }
303
+ io.write("#{lines.join("\r\n")}\r\n\r\n")
304
+ io.write(payload) if payload
305
+ io.flush if io.respond_to?(:flush)
306
+ end
307
+
308
+ # @return [Array(Integer, Hash)]
309
+ def read_raw_response_head(io)
310
+ status_line = io.gets
311
+ raise ConnectionError.new("the daemon closed the connection before answering") if status_line.nil?
312
+
313
+ status = status_line[%r{\AHTTP/\d\.\d\s+(\d+)}, 1].to_i
314
+ headers = {}
315
+ while (line = io.gets)
316
+ line = line.chomp
317
+ break if line.empty?
318
+
319
+ key, _, value = line.partition(":")
320
+ headers[key.strip.downcase] = value.strip
321
+ end
322
+ [status, headers]
323
+ end
324
+
325
+ # @return [void]
326
+ def log(method, full_path, operation)
327
+ return unless logger
328
+
329
+ logger.debug { "docker-api-ng #{operation || "request"} #{method.to_s.upcase} #{full_path}" }
330
+ end
331
+ end
332
+ end
333
+ end
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+ #
3
+ # Copyright 2026 Tim Smith
4
+ # SPDX-License-Identifier: Apache-2.0
5
+
6
+ require "digest" unless defined?(Digest)
7
+
8
+ module Docker
9
+ module API
10
+ # Docker's context store, which is where the CLI keeps the daemon it is
11
+ # pointed at when `DOCKER_HOST` is not set.
12
+ #
13
+ # `docker context use colima` does not export anything. It writes
14
+ # `currentContext` into ~/.docker/config.json, and every later `docker`
15
+ # command resolves the endpoint out of the store. A client that reads only
16
+ # `DOCKER_HOST` therefore disagrees with the CLI on the same machine, and
17
+ # falls back to /var/run/docker.sock -- which Docker Desktop happens to
18
+ # symlink, and Colima, Rancher Desktop, rootless Docker and Podman
19
+ # generally do not. The failure is a connection refused with a correct
20
+ # looking environment, which is a bad way to spend an afternoon.
21
+ #
22
+ # The store is a directory per context, named for the SHA-256 of the
23
+ # context's own name, holding a meta.json:
24
+ #
25
+ # ~/.docker/contexts/meta/<sha256(name)>/meta.json
26
+ # ~/.docker/contexts/tls/<sha256(name)>/docker/{ca,cert,key}.pem
27
+ #
28
+ # Every failure here is soft. An unreadable store, a malformed meta.json or
29
+ # a context that no longer exists all mean "no context", because falling
30
+ # back to the platform default is what the CLI does and is more useful than
31
+ # refusing to start.
32
+ module Context
33
+ # The context name that means "no context": use DOCKER_HOST, or the
34
+ # platform's default socket. It has no entry in the store.
35
+ DEFAULT = "default"
36
+
37
+ module_function
38
+
39
+ # Resolve the endpoint the CLI would use, if any.
40
+ #
41
+ # @param env [Hash] the environment to read
42
+ # @param root [String, nil] the Docker configuration directory
43
+ # @return [Hash, nil] with :url and optionally :tls, or nil when no
44
+ # context applies
45
+ def resolve(env = ENV, root: nil)
46
+ root ||= config_root(env)
47
+ name = current_name(env, root)
48
+ return nil if name.nil? || name.empty? || name == DEFAULT
49
+
50
+ endpoint(name, root)
51
+ end
52
+
53
+ # The active context's name: DOCKER_CONTEXT first, then whatever
54
+ # `docker context use` last wrote into config.json.
55
+ #
56
+ # @param env [Hash]
57
+ # @param root [String]
58
+ # @return [String, nil]
59
+ def current_name(env = ENV, root = config_root(env))
60
+ from_env = env["DOCKER_CONTEXT"]
61
+ return from_env unless from_env.nil? || from_env.empty?
62
+
63
+ config = read_json(File.join(root, "config.json"))
64
+ config.is_a?(Hash) ? config["currentContext"] : nil
65
+ end
66
+
67
+ # @param name [String] a context name
68
+ # @param root [String] the Docker configuration directory
69
+ # @return [Hash, nil] with :url and optionally :tls
70
+ def endpoint(name, root = config_root)
71
+ digest = Digest::SHA256.hexdigest(name)
72
+ meta = read_json(File.join(root, "contexts", "meta", digest, "meta.json"))
73
+ host = meta.dig("Endpoints", "docker", "Host") if meta.is_a?(Hash)
74
+ return nil if host.nil? || host.empty?
75
+
76
+ resolved = { url: host }
77
+ tls = tls_material(root, digest, skip_verify: meta.dig("Endpoints", "docker", "SkipTLSVerify"))
78
+ resolved[:tls] = tls unless tls.empty?
79
+ resolved
80
+ end
81
+
82
+ # @param env [Hash]
83
+ # @return [String] the Docker configuration directory
84
+ def config_root(env = ENV)
85
+ configured = env["DOCKER_CONFIG"]
86
+ return configured unless configured.nil? || configured.empty?
87
+
88
+ File.join(Dir.home, ".docker")
89
+ rescue ArgumentError
90
+ # Dir.home raises when there is no home to speak of, which happens in
91
+ # stripped containers and some CI images.
92
+ ".docker"
93
+ end
94
+
95
+ # A context may carry its own TLS material, which is how `docker context
96
+ # create --docker host=tcp://...,ca=...,cert=...,key=...` stores it.
97
+ #
98
+ # @return [Hash]
99
+ # @api private
100
+ def tls_material(root, digest, skip_verify: false)
101
+ directory = File.join(root, "contexts", "tls", digest, "docker")
102
+ material = { ca_file: "ca.pem", cert_file: "cert.pem", key_file: "key.pem" }
103
+ .filter_map { |key, file|
104
+ path = File.join(directory, file)
105
+ [key, path] if File.readable?(path)
106
+ }.to_h
107
+
108
+ return {} if material.empty?
109
+
110
+ material.merge(verify: !skip_verify)
111
+ end
112
+
113
+ # @return [Object, nil]
114
+ # @api private
115
+ def read_json(path)
116
+ return nil unless File.readable?(path)
117
+
118
+ JSON.parse(File.read(path))
119
+ rescue JSON::ParserError, SystemCallError
120
+ nil
121
+ end
122
+ end
123
+ end
124
+ end
@@ -0,0 +1,159 @@
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 root of every error this gem raises.
9
+ #
10
+ # Nothing beneath the abstraction escapes: no `Errno`, no `OpenSSL`
11
+ # exception, no `Net::` class and no `JSON::ParserError` reaches a caller's
12
+ # rescue clause. The original is always retained as {#cause}, so nothing is
13
+ # lost -- but consumers get to rescue one hierarchy instead of coupling
14
+ # themselves to whichever HTTP library happens to be underneath.
15
+ #
16
+ # @example Rescuing anything this gem can raise
17
+ # begin
18
+ # client.containers.get("missing")
19
+ # rescue Docker::API::Error => e
20
+ # warn "#{e.operation} failed: #{e.message}"
21
+ # end
22
+ class Error < StandardError
23
+ # @return [String, nil] the operation that failed, e.g. "container_start"
24
+ attr_reader :operation
25
+
26
+ # @return [Integer, nil] the HTTP status the daemon returned
27
+ attr_reader :status
28
+
29
+ # @return [Docker::API::Response, nil] the response, when there was one
30
+ attr_reader :response
31
+
32
+ # @param message [String, nil] a message, or nil to compose one
33
+ # @param operation [String, Symbol, nil] the operation that failed
34
+ # @param status [Integer, nil] the HTTP status
35
+ # @param response [Docker::API::Response, nil] the response
36
+ def initialize(message = nil, operation: nil, status: nil, response: nil)
37
+ @operation = operation&.to_s
38
+ @status = status
39
+ @response = response
40
+ super(message || compose_message)
41
+ end
42
+
43
+ # Build the error class appropriate to an HTTP status.
44
+ #
45
+ # @param status [Integer] the HTTP status the daemon returned
46
+ # @param operation [String, Symbol, nil] the operation that failed
47
+ # @param response [Docker::API::Response, nil] the response
48
+ # @return [Docker::API::Error] an instance of the mapped subclass
49
+ def self.for(status:, operation: nil, response: nil)
50
+ klass_for(status).new(nil, operation: operation, status: status, response: response)
51
+ end
52
+
53
+ # @param status [Integer] an HTTP status
54
+ # @return [Class] the error class that status maps to
55
+ # @api private
56
+ def self.klass_for(status)
57
+ STATUS_MAP.fetch(status) do
58
+ case status
59
+ when 400..499 then ClientError
60
+ when 500..599 then ServerError
61
+ else Error
62
+ end
63
+ end
64
+ end
65
+ private_class_method :klass_for
66
+
67
+ private
68
+
69
+ # Compose the most informative message available: what was attempted,
70
+ # what the daemon said about it, and the status it said it with.
71
+ #
72
+ # @return [String]
73
+ def compose_message
74
+ parts = []
75
+ parts << (operation ? "#{operation} failed" : "request failed")
76
+ parts << "(HTTP #{status})" if status
77
+ detail = daemon_message
78
+ parts << ": #{detail}" if detail && !detail.empty?
79
+ parts.join(" ").sub(" :", ":")
80
+ end
81
+
82
+ # The daemon reports its own reason in a JSON `message` field. Fall back
83
+ # to the raw body when the response is not JSON, because an unparsed body
84
+ # is still more use to a human than nothing at all.
85
+ #
86
+ # @return [String, nil]
87
+ def daemon_message
88
+ return nil if response.nil?
89
+
90
+ body = response.body
91
+ return nil if body.nil? || body.empty?
92
+
93
+ parsed = begin
94
+ JSON.parse(body)
95
+ rescue JSON::ParserError
96
+ nil
97
+ end
98
+
99
+ return body.strip unless parsed.is_a?(Hash)
100
+
101
+ parsed["message"] || parsed["Message"] || body.strip
102
+ end
103
+ end
104
+
105
+ # The daemon could not be reached at all: refused, reset, unresolvable, or
106
+ # a TLS handshake that failed.
107
+ class ConnectionError < Error; end
108
+
109
+ # The daemon accepted the connection but did not answer in time.
110
+ class TimeoutError < Error; end
111
+
112
+ # The request was wrong. 4xx.
113
+ class ClientError < Error; end
114
+
115
+ # 400 -- the daemon could not parse or accept the request.
116
+ class BadRequest < ClientError; end
117
+
118
+ # 401 -- authentication is required or was rejected.
119
+ class Unauthorized < ClientError; end
120
+
121
+ # 403 -- the daemon understood but refuses.
122
+ class Forbidden < ClientError; end
123
+
124
+ # 404 -- no such container, image, network, volume or endpoint.
125
+ class NotFound < ClientError; end
126
+
127
+ # 304 -- the requested change was already true. Docker returns this for,
128
+ # among others, starting an already-running container, which is why it is
129
+ # grouped with the client errors rather than treated as a redirect.
130
+ class NotModified < ClientError; end
131
+
132
+ # 409 -- the request conflicts with the daemon's current state, such as a
133
+ # container name already in use.
134
+ class Conflict < ClientError; end
135
+
136
+ # 5xx -- the daemon failed while handling a request it accepted.
137
+ class ServerError < Error; end
138
+
139
+ # The daemon speaks an API version this gem cannot work with.
140
+ class VersionUnsupported < Error; end
141
+
142
+ # A response stream was malformed, truncated, or not the format the
143
+ # endpoint promised.
144
+ class StreamError < Error; end
145
+
146
+ # Statuses with a specific meaning worth its own class. Anything else falls
147
+ # back to ClientError or ServerError by range.
148
+ #
149
+ # @api private
150
+ STATUS_MAP = {
151
+ 304 => NotModified,
152
+ 400 => BadRequest,
153
+ 401 => Unauthorized,
154
+ 403 => Forbidden,
155
+ 404 => NotFound,
156
+ 409 => Conflict,
157
+ }.freeze
158
+ end
159
+ end